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,365 @@
package achievement
import (
"context"
"errors"
"fmt"
"sort"
"strconv"
"strings"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const (
titleBookListType byte = 100
titleBookSlotBase int16 = 1000
titleBookMaxPerCategory = 200
statusActive = "title_book_active"
statusCompleted = "title_book_completed"
extraInitialized = "title_book_initialized"
extraRemain1 = "title_book_remain_1"
extraRemain2 = "title_book_remain_2"
extraRemain3 = "title_book_remain_3"
)
var (
ErrOwnerUnavailable = errors.New("achievement owner unavailable")
ErrCharacterRequired = errors.New("selected character id required")
ErrInventoryNotFound = errors.New("achievement inventory not found")
ErrDefinitionNotFound = errors.New("achievement definition not found")
)
type TitleReward struct {
ItemID int32
Category int32
BookIndex int32
}
type Definition struct {
QuestID int32
Target1 uint16
Target2 uint16
Target3 uint16
Reward TitleReward
}
type DefinitionResolver interface {
ResolveAchievementDefinition(context.Context, int32) (Definition, error)
}
type Command struct {
SelectedCharacterID uint16
QuestID int32
Delta1 uint16
Delta2 uint16
Delta3 uint16
UpdatedAt time.Time
}
type Result struct {
CharacterID string
QuestID int32
Remain1 uint16
Remain2 uint16
Remain3 uint16
Completed bool
AlreadyCompleted bool
TitleGranted bool
TitleItemID int32
TitleCategory int32
TitleSlot int16
LegacyRowsRemoved int
}
type Progress struct {
QuestID int32
Remain1 uint16
Remain2 uint16
Remain3 uint16
Completed bool
}
// Owner atomically owns the durable title-achievement state and its optional
// physical title reward. Progress deliberately lives in Quest.Progress, not
// inventory list 100: list 100 is the client-visible physical title book.
type Owner struct {
settlements dnfrepo.CharacterSettlementUnitOfWork
resolver DefinitionResolver
}
func NewOwner(repos dnfrepo.Group, resolver DefinitionResolver) (*Owner, error) {
if repos.CharacterSettlement == nil || repos.Quest == nil || repos.Inventory == nil {
return nil, ErrOwnerUnavailable
}
return &Owner{settlements: repos.CharacterSettlement, resolver: resolver}, nil
}
func (o *Owner) Trigger(ctx context.Context, cmd Command) (Result, error) {
if o == nil || o.settlements == nil || o.resolver == nil {
return Result{}, ErrOwnerUnavailable
}
if cmd.SelectedCharacterID == 0 {
return Result{}, ErrCharacterRequired
}
definition, err := o.resolver.ResolveAchievementDefinition(ctx, cmd.QuestID)
if err != nil {
return Result{}, err
}
if definition.QuestID != cmd.QuestID ||
(definition.Target1 == 0 && definition.Target2 == 0 && definition.Target3 == 0) {
return Result{}, fmt.Errorf("%w: quest=%d", ErrDefinitionNotFound, cmd.QuestID)
}
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
result := Result{CharacterID: characterID, QuestID: cmd.QuestID}
err = o.settlements.WithinCharacterSettlement(ctx, characterID, func(tx dnfrepo.Group) error {
quests, _, loadErr := tx.Quest.Load(ctx, characterID)
if loadErr != nil {
return loadErr
}
quests = dnfrepo.CloneQuest(quests)
quests.CharacterID = characterID
if quests.Progress == nil {
quests.Progress = make(map[int64]dnfrepo.QuestState)
}
inventory, found, loadErr := tx.Inventory.Load(ctx, characterID)
if loadErr != nil {
return loadErr
}
if !found || strings.TrimSpace(inventory.CharacterID) != characterID {
return fmt.Errorf("%w: character=%s", ErrInventoryNotFound, characterID)
}
inventory = dnfrepo.CloneInventory(inventory)
if inventory.Slots == nil {
inventory.Slots = make(map[string]dnfrepo.ItemStack)
}
for key, stack := range inventory.Slots {
if IsLegacyInventoryProgress(key, stack) {
delete(inventory.Slots, key)
result.LegacyRowsRemoved++
}
}
state, known := quests.Progress[int64(cmd.QuestID)]
if known && strings.EqualFold(strings.TrimSpace(state.Status), statusCompleted) {
result.AlreadyCompleted = true
if result.LegacyRowsRemoved > 0 {
inventory.UpdatedAt = commandTime(cmd.UpdatedAt)
return dnfrepo.SaveInventoryFields(ctx, tx.Inventory, inventory, dnfrepo.InventoryFieldSlots)
}
return nil
}
remain1, remain2, remain3 := definition.Target1, definition.Target2, definition.Target3
if known && strings.EqualFold(strings.TrimSpace(state.Status), statusActive) &&
state.Extra[extraInitialized] == "1" {
remain1 = extraUint16(state.Extra, extraRemain1, definition.Target1)
remain2 = extraUint16(state.Extra, extraRemain2, definition.Target2)
remain3 = extraUint16(state.Extra, extraRemain3, definition.Target3)
}
remain1 = subtractProgress(remain1, cmd.Delta1)
remain2 = subtractProgress(remain2, cmd.Delta2)
remain3 = subtractProgress(remain3, cmd.Delta3)
now := commandTime(cmd.UpdatedAt)
state.Status = statusActive
state.ProgressValue = int64(remain1)
state.UpdatedAt = now
if state.Extra == nil {
state.Extra = make(map[string]string, 4)
}
state.Extra[extraInitialized] = "1"
state.Extra[extraRemain1] = strconv.FormatUint(uint64(remain1), 10)
state.Extra[extraRemain2] = strconv.FormatUint(uint64(remain2), 10)
state.Extra[extraRemain3] = strconv.FormatUint(uint64(remain3), 10)
result.Remain1 = remain1
result.Remain2 = remain2
result.Remain3 = remain3
result.Completed = remain1 == 0 && remain2 == 0 && remain3 == 0
if result.Completed {
state.Status = statusCompleted
state.ProgressValue = 0
grantTitle(&inventory, definition.Reward, cmd.QuestID, &result)
}
quests.Progress[int64(cmd.QuestID)] = state
quests.UpdatedAt = now
inventory.UpdatedAt = now
if err := dnfrepo.SaveQuestFields(ctx, tx.Quest, quests, dnfrepo.QuestFieldProgress); err != nil {
return err
}
if result.TitleGranted || result.LegacyRowsRemoved > 0 {
if err := dnfrepo.SaveInventoryFields(ctx, tx.Inventory, inventory, dnfrepo.InventoryFieldSlots); err != nil {
return err
}
}
return nil
})
if err != nil {
return Result{}, err
}
return result, nil
}
// RepairLegacyRows removes only rows carrying the exact signature emitted by
// the former broken implementation. Their stored counters were initialized
// from the first delta and are not trustworthy enough to migrate.
func (o *Owner) RepairLegacyRows(ctx context.Context, selectedCharacterID uint16) (int, error) {
if o == nil || o.settlements == nil {
return 0, ErrOwnerUnavailable
}
if selectedCharacterID == 0 {
return 0, ErrCharacterRequired
}
characterID := strconv.FormatUint(uint64(selectedCharacterID), 10)
removed := 0
err := o.settlements.WithinCharacterSettlement(ctx, characterID, func(tx dnfrepo.Group) error {
inventory, found, err := tx.Inventory.Load(ctx, characterID)
if err != nil {
return err
}
if !found {
return nil
}
inventory = dnfrepo.CloneInventory(inventory)
for key, stack := range inventory.Slots {
if IsLegacyInventoryProgress(key, stack) {
delete(inventory.Slots, key)
removed++
}
}
if removed == 0 {
return nil
}
inventory.UpdatedAt = time.Now().UTC()
return dnfrepo.SaveInventoryFields(ctx, tx.Inventory, inventory, dnfrepo.InventoryFieldSlots)
})
return removed, err
}
func Snapshot(record dnfrepo.QuestRecord) []Progress {
rows := make([]Progress, 0)
for questID, state := range record.Progress {
status := strings.ToLower(strings.TrimSpace(state.Status))
if status != statusActive && status != statusCompleted {
continue
}
if questID <= 0 || questID > int64(^uint32(0)) {
continue
}
row := Progress{QuestID: int32(questID), Completed: status == statusCompleted}
if !row.Completed {
row.Remain1 = extraUint16(state.Extra, extraRemain1, 0)
row.Remain2 = extraUint16(state.Extra, extraRemain2, 0)
row.Remain3 = extraUint16(state.Extra, extraRemain3, 0)
}
rows = append(rows, row)
}
sort.Slice(rows, func(i, j int) bool { return rows[i].QuestID < rows[j].QuestID })
return rows
}
func IsLegacyInventoryProgress(key string, stack dnfrepo.ItemStack) bool {
if stack.Extra == nil || stack.Extra["initialized"] != "1" {
return false
}
questID, err := strconv.ParseInt(strings.TrimSpace(stack.Extra["quest_id"]), 10, 32)
if err != nil || questID <= 0 || stack.ItemID != questID {
return false
}
parts := strings.Split(key, ":")
if len(parts) != 2 || parts[0] != strconv.Itoa(int(titleBookListType)) {
return false
}
slot, err := strconv.Atoi(parts[1])
return err == nil && slot >= 900 && slot <= 999
}
func grantTitle(inventory *dnfrepo.InventoryRecord, reward TitleReward, questID int32, result *Result) {
if inventory == nil || result == nil || reward.ItemID <= 0 ||
reward.Category < 0 || reward.Category >= 5 {
return
}
for index := int32(0); index < titleBookMaxPerCategory; index++ {
slot := int16(reward.Category)*titleBookSlotBase + int16(index)
if existing, found := inventory.Slots[titleSlotKey(slot)]; found &&
existing.ItemID == int64(reward.ItemID) {
result.TitleItemID = reward.ItemID
result.TitleCategory = reward.Category
result.TitleSlot = slot
return
}
}
targetSlot, ok := firstTitleSlot(inventory.Slots, reward.Category, reward.BookIndex)
if !ok {
return
}
key := titleSlotKey(targetSlot)
inventory.Slots[key] = dnfrepo.ItemStack{
ItemID: int64(reward.ItemID),
Count: 1,
Extra: map[string]string{
"source": "achievement_reward",
"title_book_category": strconv.FormatInt(int64(reward.Category), 10),
"title_book_index": strconv.FormatInt(int64(targetSlot%titleBookSlotBase), 10),
"quest_id": strconv.FormatInt(int64(questID), 10),
},
}
result.TitleGranted = true
result.TitleItemID = reward.ItemID
result.TitleCategory = reward.Category
result.TitleSlot = targetSlot
}
func titleSlotKey(slot int16) string {
return fmt.Sprintf("%d:%d", titleBookListType, slot)
}
func categorySlotKey(category, index int32) string {
return titleSlotKey(int16(category)*titleBookSlotBase + int16(index))
}
func firstTitleSlot(items map[string]dnfrepo.ItemStack, category, preferredIndex int32) (int16, bool) {
if preferredIndex >= 0 && preferredIndex < titleBookMaxPerCategory {
slot := int16(category)*titleBookSlotBase + int16(preferredIndex)
existing, occupied := items[titleSlotKey(slot)]
if !occupied || existing.ItemID <= 0 {
return slot, true
}
}
for index := int32(0); index < titleBookMaxPerCategory; index++ {
existing, occupied := items[categorySlotKey(category, index)]
if !occupied || existing.ItemID <= 0 {
return int16(category)*titleBookSlotBase + int16(index), true
}
}
return -1, false
}
func extraUint16(extra map[string]string, key string, fallback uint16) uint16 {
value, err := strconv.ParseUint(strings.TrimSpace(extra[key]), 10, 16)
if err != nil {
return fallback
}
return uint16(value)
}
func subtractProgress(remaining, delta uint16) uint16 {
if delta >= remaining {
return 0
}
return remaining - delta
}
func commandTime(value time.Time) time.Time {
if value.IsZero() {
return time.Now().UTC()
}
return value.UTC()
}
@@ -0,0 +1,163 @@
package achievement
import (
"context"
"testing"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
type definitionStub Definition
func (r definitionStub) ResolveAchievementDefinition(context.Context, int32) (Definition, error) {
return Definition(r), nil
}
func TestOwnerTriggerPersistsRealTargetAndGrantsTitleOnce(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{},
}); err != nil {
t.Fatalf("save inventory: %v", err)
}
owner, err := NewOwner(repos, definitionStub{
QuestID: 6533,
Target1: 4,
Reward: TitleReward{
ItemID: 9001,
Category: 2,
BookIndex: 7,
},
})
if err != nil {
t.Fatalf("NewOwner: %v", err)
}
first, err := owner.Trigger(ctx, Command{
SelectedCharacterID: 19,
QuestID: 6533,
Delta1: 1,
})
if err != nil {
t.Fatalf("first Trigger: %v", err)
}
if first.Remain1 != 3 || first.Completed {
t.Fatalf("first result = %+v", first)
}
second, err := owner.Trigger(ctx, Command{
SelectedCharacterID: 19,
QuestID: 6533,
Delta1: 3,
})
if err != nil {
t.Fatalf("second Trigger: %v", err)
}
if !second.Completed || !second.TitleGranted || second.TitleSlot != 2007 {
t.Fatalf("second result = %+v", second)
}
third, err := owner.Trigger(ctx, Command{
SelectedCharacterID: 19,
QuestID: 6533,
Delta1: 1,
})
if err != nil {
t.Fatalf("third Trigger: %v", err)
}
if third.Completed || third.TitleGranted || !third.AlreadyCompleted ||
third.Remain1 != 0 || third.Remain2 != 0 || third.Remain3 != 0 {
t.Fatalf("third result = %+v", third)
}
inventory, found, err := repos.Inventory.Load(ctx, "19")
if err != nil || !found {
t.Fatalf("load inventory found=%t err=%v", found, err)
}
if title := inventory.Slots["100:2007"]; title.ItemID != 9001 || title.Count != 1 {
t.Fatalf("title = %+v", title)
}
for key, stack := range inventory.Slots {
if IsLegacyInventoryProgress(key, stack) {
t.Fatalf("achievement progress leaked into title inventory: %s=%+v", key, stack)
}
}
quests, found, err := repos.Quest.Load(ctx, "19")
if err != nil || !found {
t.Fatalf("load quests found=%t err=%v", found, err)
}
progress := Snapshot(quests)
if len(progress) != 1 || progress[0].QuestID != 6533 || !progress[0].Completed {
t.Fatalf("progress = %+v", progress)
}
}
func TestOwnerTriggerFallsBackToFirstFreeTitleSlot(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{
"100:2007": {ItemID: 8001, Count: 1},
"100:2000": {ItemID: 8002, Count: 1},
},
}); err != nil {
t.Fatalf("save inventory: %v", err)
}
owner, err := NewOwner(repos, definitionStub{
QuestID: 6533,
Target1: 1,
Reward: TitleReward{ItemID: 9001, Category: 2, BookIndex: 7},
})
if err != nil {
t.Fatalf("NewOwner: %v", err)
}
result, err := owner.Trigger(ctx, Command{SelectedCharacterID: 19, QuestID: 6533, Delta1: 1})
if err != nil {
t.Fatalf("Trigger: %v", err)
}
if result.TitleSlot != 2001 {
t.Fatalf("title slot = %d, want 2001", result.TitleSlot)
}
}
func TestRepairLegacyRowsRemovesOnlyBrokenProgressSignature(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{
"100:933": {
ItemID: 6533,
Count: 1,
Extra: map[string]string{
"initialized": "1",
"quest_id": "6533",
"remain1": "1",
},
},
"100:7": {ItemID: 9001, Count: 1},
},
}); err != nil {
t.Fatalf("save inventory: %v", err)
}
owner, err := NewOwner(repos, nil)
if err != nil {
t.Fatalf("NewOwner: %v", err)
}
removed, err := owner.RepairLegacyRows(ctx, 19)
if err != nil {
t.Fatalf("RepairLegacyRows: %v", err)
}
if removed != 1 {
t.Fatalf("removed = %d, want 1", removed)
}
inventory, _, _ := repos.Inventory.Load(ctx, "19")
if _, found := inventory.Slots["100:933"]; found {
t.Fatalf("legacy progress survived: %+v", inventory.Slots["100:933"])
}
if inventory.Slots["100:7"].ItemID != 9001 {
t.Fatalf("real title was removed: %+v", inventory.Slots)
}
}
@@ -0,0 +1,90 @@
package actionobject
import (
"os"
"testing"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
platformpvf "longheng.io/server/internal/platform/pvf"
)
// TestRealScriptPVFEnergySaveGateScripts decodes the shipped 能源储藏室 scripts
// rather than the hand-written fixtures above, so a grammar the fixtures do not
// reproduce cannot pass unnoticed.
func TestRealScriptPVFEnergySaveGateScripts(t *testing.T) {
pvfPath := os.Getenv("DNF_WORLDMAP_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNF_WORLDMAP_REAL_PVF_SMOKE to decode the shipped action-object scripts")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatal(err)
}
const base = "passiveobject/actionobject/map/luke_raid/phase2/enegy_save/action/"
read := func(name string) Script {
t.Helper()
text, readErr := archive.ReadText(base + name)
if readErr != nil {
t.Fatalf("read %s: %v", name, readErr)
}
document, parseErr := dnfpvf.Parse(base+name, text)
if parseErr != nil {
t.Fatalf("parse %s: %v", name, parseErr)
}
script, decodeErr := Parse(document)
if decodeErr != nil {
t.Fatalf("decode %s: %v", name, decodeErr)
}
return script
}
gate := read("gate_control_gate.act")
if len(gate.Triggers) != 2 || len(gate.Behaviors) != 2 {
t.Fatalf("gate script=%+v", gate)
}
for index, want := range []Condition{
{Kind: ConditionDungeonSymbol, Symbol: "storage gate open", Op: CompareLess, Value: 2},
{Kind: ConditionDungeonSymbol, Symbol: "storage gate open", Op: CompareGreaterEqual, Value: 2},
} {
conditions := gate.Triggers[index].Conditions
if len(conditions) != 1 || conditions[0] != want {
t.Fatalf("gate trigger %d conditions=%+v want %+v (unsupported=%v)",
index, conditions, want, gate.Triggers[index].Unsupported)
}
}
if gate.Triggers[0].Enabled == gate.Triggers[1].Enabled {
t.Fatalf("gate triggers must ship in opposite states: %+v", gate.Triggers)
}
opened := false
for _, operation := range gate.Behaviors[1].Operations {
if operation.Kind == OperationGateEnable && operation.GateEnabled &&
len(operation.GateDirections) == 1 && operation.GateDirections[0] == "down" {
opened = true
}
}
if !opened {
t.Fatalf("gate behavior 1 never opens the down gate: %+v", gate.Behaviors[1])
}
for name, monsterID := range map[string]int64{
"gate_control_left.act": 57156,
"gate_control_right.act": 56068,
} {
cylinder := read(name)
if len(cylinder.Triggers) != 1 || len(cylinder.Behaviors) != 1 {
t.Fatalf("%s=%+v", name, cylinder)
}
conditions := cylinder.Triggers[0].Conditions
want := Condition{Kind: ConditionMonsterCount, MonsterID: monsterID, Op: CompareLess, Value: 1}
if len(conditions) != 1 || conditions[0] != want {
t.Fatalf("%s conditions=%+v want %+v (unsupported=%v)",
name, conditions, want, cylinder.Triggers[0].Unsupported)
}
operations := cylinder.Behaviors[0].Operations
if len(operations) != 1 || operations[0].Kind != OperationSymbolCalc ||
operations[0].Symbol != "storage gate open" ||
operations[0].Calc != "+" || operations[0].Value != 1 {
t.Fatalf("%s operations=%+v", name, operations)
}
}
}
@@ -0,0 +1,513 @@
// Package actionobject parses the PVF action-object scripts that a dungeon's
// passive objects run.
//
// A placed `[passive object]` resolves through passiveobject/passiveobject.lst
// to an `.obj` definition whose `[basic action]` names an `.act` script. The
// script is a flat, ordered sequence of `[TRIGGER]` / `[BEHAVIOR]` blocks: a
// trigger tests dungeon symbols and room actors and names the behavior it runs,
// a behavior mutates dungeon symbols, opens or closes room gates and posts
// messages.
//
// Only the operations the server can actually honour are decoded. Everything
// else is recorded by name on the owning block so a caller can log what it
// skipped rather than silently mis-running a script.
package actionobject
import (
"errors"
"strings"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
)
var ErrDocumentRequired = errors.New("dnf action object document is required")
// CompareOp is the comparison a `[CHECKUP]` or `[DUNGEON SYMBOL COMPARE]`
// applies. The script spells it as its own section, e.g. `[<]` then the operand.
type CompareOp string
const (
CompareLess CompareOp = "<"
CompareLessEqual CompareOp = "<="
CompareEqual CompareOp = "="
CompareNotEqual CompareOp = "!="
CompareGreaterEqual CompareOp = ">="
CompareGreater CompareOp = ">"
)
// Satisfied reports whether left <op> right holds.
func (op CompareOp) Satisfied(left, right int64) bool {
switch op {
case CompareLess:
return left < right
case CompareLessEqual:
return left <= right
case CompareEqual:
return left == right
case CompareNotEqual:
return left != right
case CompareGreaterEqual:
return left >= right
case CompareGreater:
return left > right
default:
return false
}
}
type ConditionKind string
const (
// ConditionDungeonSymbol is `[DUNGEON SYMBOL COMPARE] name` + `[<] n`.
ConditionDungeonSymbol ConditionKind = "dungeon_symbol"
// ConditionMonsterCount is `[WHICH][MONSTER][CHECKUP][IS INDEX] id
// [/IS INDEX][CHECKED NO][<] n [/CHECKUP]`: how many live monsters of that
// index the room still holds.
ConditionMonsterCount ConditionKind = "monster_count"
)
type Condition struct {
Kind ConditionKind
Symbol string
MonsterID int64
Op CompareOp
Value int64
}
type OperationKind string
const (
OperationSymbolCalc OperationKind = "dungeon_symbol_calc"
OperationSymbolSet OperationKind = "dungeon_symbol_set"
OperationGateEnable OperationKind = "gate_enable"
OperationNotice OperationKind = "notice"
OperationCenterMessage OperationKind = "center_msg"
OperationTriggerSwitch OperationKind = "trigger_switch"
OperationDestroy OperationKind = "destroy"
)
type Operation struct {
Kind OperationKind
Symbol string
// Calc is `+` or `-` for OperationSymbolCalc.
Calc string
Value int64
// GateEnabled and GateDirections carry `[GATE ENABLE] x y enable` plus the
// `[DOWN]` / `[LEFT]` / `[RIGHT]` / `[UP]` sections inside its block.
GateEnabled bool
GateDirections []string
Text string
// Color is the ARGB the script's `r g b` triple builds, opaque.
Color uint32
DurationMS int64
TriggerIndex int
TriggerOn bool
}
type Trigger struct {
Index int
// Limit is how many times the trigger may fire; 0 means unlimited.
Limit int
// Enabled is the trigger's state before any behavior switches it. A script
// writes `[ENABLE][OFF]` for a trigger another behavior turns on later.
Enabled bool
Conditions []Condition
Behavior int
Unsupported []string
}
type Behavior struct {
Index int
Operations []Operation
Unsupported []string
}
type Script struct {
Path string
Triggers []Trigger
Behaviors []Behavior
}
// Drives reports whether the script touches anything the dungeon runtime owns.
// A script that only plays animations is not worth instantiating.
func (s Script) Drives() bool {
for _, behavior := range s.Behaviors {
for _, operation := range behavior.Operations {
switch operation.Kind {
case OperationSymbolCalc, OperationSymbolSet, OperationGateEnable,
OperationNotice, OperationCenterMessage:
return true
}
}
}
return false
}
func compareOpFor(name string) (CompareOp, bool) {
switch CompareOp(name) {
case CompareLess, CompareLessEqual, CompareEqual, CompareNotEqual, CompareGreaterEqual, CompareGreater:
return CompareOp(name), true
case "==":
return CompareEqual, true
case "<>":
return CompareNotEqual, true
default:
return "", false
}
}
func gateDirectionFor(name string) (string, bool) {
switch name {
case "up", "down", "left", "right":
return name, true
default:
return "", false
}
}
// parseState is the cursor the section walk carries. `[TRIGGER]` is overloaded:
// at the top level it opens a trigger block, inside a `[BEHAVIOR]` it is the
// "switch trigger n on/off" operation, told apart by the integer operand.
type parseState struct {
script Script
trigger *Trigger
behavior *Behavior
pendingCompare *Condition
pendingMonster int64
monsterSelected bool
checkupOpen bool
checkedNo bool
enablePending bool
gateOpen bool
doBehavior bool
triggerSwitch bool
// pendingCalcIndex is the behavior operation waiting for its `[+] 1`
// operator section; -1 when none is open.
pendingCalcIndex int
}
// Parse decodes one `.act` document.
func Parse(document *dnfpvf.Document) (Script, error) {
if document == nil {
return Script{}, ErrDocumentRequired
}
state := parseState{script: Script{Path: document.Path}, pendingCalcIndex: -1}
for _, section := range document.Sections {
name := strings.ToLower(strings.TrimSpace(section.Name))
tokens := sectionTokens(document, section)
state.consume(name, tokens)
}
state.closeTrigger()
state.closeBehavior()
return state.script, nil
}
func sectionTokens(document *dnfpvf.Document, section dnfpvf.Section) []dnfpvf.Token {
if section.Start < 0 || section.End > len(document.Tokens) || section.Start > section.End {
return nil
}
return document.Tokens[section.Start:section.End]
}
func (p *parseState) consume(name string, tokens []dnfpvf.Token) {
switch name {
case "motion", "/motion", "base ani":
return
case "trigger":
// Inside a behavior this is `[TRIGGER] n` + `[ON]`/`[OFF]`.
if p.behavior != nil {
p.triggerSwitch = true
p.behavior.Operations = append(p.behavior.Operations, Operation{
Kind: OperationTriggerSwitch,
TriggerIndex: int(firstInt(tokens)),
TriggerOn: true,
})
return
}
p.closeTrigger()
p.trigger = &Trigger{Index: len(p.script.Triggers), Enabled: true, Behavior: -1}
case "/trigger":
p.closeTrigger()
case "behavior":
p.closeBehavior()
p.behavior = &Behavior{Index: len(p.script.Behaviors)}
case "/behavior":
p.closeBehavior()
case "limit":
if p.trigger != nil {
p.trigger.Limit = int(firstInt(tokens))
}
case "enable":
p.enablePending = true
case "on", "off":
p.applySwitch(name == "on")
case "which":
p.monsterSelected = false
case "monster":
if p.trigger != nil {
p.monsterSelected = true
}
case "checkup":
p.checkupOpen = true
p.checkedNo = false
p.pendingMonster = 0
case "/checkup":
p.checkupOpen = false
p.checkedNo = false
p.monsterSelected = false
p.pendingMonster = 0
case "is index":
p.pendingMonster = firstInt(tokens)
case "/is index":
case "checked no":
p.checkedNo = true
case "dungeon symbol compare":
p.pendingCompare = &Condition{Kind: ConditionDungeonSymbol, Symbol: firstText(tokens)}
case "dungeon symbol calc":
p.appendSymbolCalc(tokens)
case "dungeon symbol set":
if p.behavior != nil {
p.behavior.Operations = append(p.behavior.Operations, Operation{
Kind: OperationSymbolSet,
Symbol: firstText(tokens),
Value: firstInt(tokens),
})
}
case "gate enable":
p.openGate(tokens)
case "/gate enable":
p.gateOpen = false
case "notice":
p.appendMessage(OperationNotice, tokens)
case "/notice":
case "center msg":
p.appendMessage(OperationCenterMessage, tokens)
case "destroy":
if p.behavior != nil {
p.behavior.Operations = append(p.behavior.Operations, Operation{Kind: OperationDestroy})
}
case "do behavior":
p.doBehavior = true
case "me":
p.applyMe(tokens)
case "frame":
// A frame gate is animation timing, not a gameplay condition: the
// server has no animation clock, so the trigger is treated as always
// reachable and its other conditions decide.
default:
p.consumeUnknown(name, tokens)
}
}
func (p *parseState) consumeUnknown(name string, tokens []dnfpvf.Token) {
// `[DUNGEON SYMBOL CALC] name` is finished by its operator section, e.g.
// `[+]` then 1. `[=]` is also a comparison, so the open calc wins while one
// is pending and the trigger's comparison table is consulted otherwise.
if p.pendingCalcIndex >= 0 && p.behavior != nil &&
(name == "+" || name == "-" || name == "=") {
operation := &p.behavior.Operations[p.pendingCalcIndex]
if name == "=" {
operation.Kind = OperationSymbolSet
} else {
operation.Calc = name
}
operation.Value = firstInt(tokens)
p.pendingCalcIndex = -1
return
}
if op, ok := compareOpFor(name); ok {
p.applyCompare(op, firstInt(tokens))
return
}
if direction, ok := gateDirectionFor(name); ok && p.gateOpen && p.behavior != nil {
last := len(p.behavior.Operations) - 1
if last >= 0 && p.behavior.Operations[last].Kind == OperationGateEnable {
p.behavior.Operations[last].GateDirections = append(
p.behavior.Operations[last].GateDirections, direction)
}
return
}
switch {
case p.behavior != nil:
p.behavior.Unsupported = appendUnique(p.behavior.Unsupported, name)
case p.trigger != nil:
p.trigger.Unsupported = appendUnique(p.trigger.Unsupported, name)
}
}
// applyCompare closes whichever comparison was left open: a dungeon symbol
// compare, or the live-monster count of a `[CHECKUP]`.
func (p *parseState) applyCompare(op CompareOp, value int64) {
switch {
case p.pendingCompare != nil:
condition := *p.pendingCompare
condition.Op = op
condition.Value = value
p.pendingCompare = nil
if p.trigger != nil {
p.trigger.Conditions = append(p.trigger.Conditions, condition)
}
case p.checkupOpen && p.checkedNo && p.monsterSelected && p.trigger != nil:
p.trigger.Conditions = append(p.trigger.Conditions, Condition{
Kind: ConditionMonsterCount,
MonsterID: p.pendingMonster,
Op: op,
Value: value,
})
p.checkedNo = false
case p.trigger != nil:
p.trigger.Unsupported = appendUnique(p.trigger.Unsupported, string(op))
}
}
func (p *parseState) applySwitch(on bool) {
switch {
case p.triggerSwitch && p.behavior != nil:
last := len(p.behavior.Operations) - 1
if last >= 0 && p.behavior.Operations[last].Kind == OperationTriggerSwitch {
p.behavior.Operations[last].TriggerOn = on
}
p.triggerSwitch = false
case p.enablePending && p.trigger != nil:
p.trigger.Enabled = on
p.enablePending = false
}
}
func (p *parseState) applyMe(tokens []dnfpvf.Token) {
if p.doBehavior && p.trigger != nil {
p.trigger.Behavior = int(firstInt(tokens))
}
p.doBehavior = false
}
func (p *parseState) appendSymbolCalc(tokens []dnfpvf.Token) {
if p.behavior == nil {
return
}
p.behavior.Operations = append(p.behavior.Operations, Operation{
Kind: OperationSymbolCalc,
Symbol: firstText(tokens),
Calc: "+",
})
// The operator and operand arrive as their own sections, e.g. `[+]` then 1.
p.pendingCalcIndex = len(p.behavior.Operations) - 1
p.pendingCompare = nil
}
func (p *parseState) openGate(tokens []dnfpvf.Token) {
if p.behavior == nil {
return
}
// `[GATE ENABLE] x y enable`: the coordinate pair is -1 -1 for "this room"
// in every shipped script, and only the third field decides open/closed.
values := allInts(tokens)
enabled := false
if len(values) > 0 && values[len(values)-1] != 0 {
enabled = true
}
p.gateOpen = true
p.behavior.Operations = append(p.behavior.Operations, Operation{
Kind: OperationGateEnable,
GateEnabled: enabled,
})
}
func (p *parseState) appendMessage(kind OperationKind, tokens []dnfpvf.Token) {
if p.behavior == nil {
return
}
operation := Operation{Kind: kind, Text: firstText(tokens), Color: 0xFFFFFFFF}
values := allInts(tokens)
switch kind {
case OperationNotice:
// `[NOTICE] text r g b sound fade duration`.
if len(values) >= 3 {
operation.Color = 0xFF000000 |
uint32(clampByte(values[0]))<<16 |
uint32(clampByte(values[1]))<<8 |
uint32(clampByte(values[2]))
}
if len(values) >= 5 {
operation.DurationMS = values[3]
}
case OperationCenterMessage:
// `[CENTER MSG] text duration`.
if len(values) >= 1 {
operation.DurationMS = values[0]
}
}
p.behavior.Operations = append(p.behavior.Operations, operation)
}
func (p *parseState) closeTrigger() {
if p.trigger == nil {
return
}
p.script.Triggers = append(p.script.Triggers, *p.trigger)
p.trigger = nil
p.pendingCompare = nil
p.monsterSelected = false
p.checkupOpen = false
p.checkedNo = false
p.enablePending = false
p.doBehavior = false
}
func (p *parseState) closeBehavior() {
if p.behavior == nil {
return
}
p.script.Behaviors = append(p.script.Behaviors, *p.behavior)
p.behavior = nil
p.gateOpen = false
p.triggerSwitch = false
p.pendingCalcIndex = -1
}
func firstInt(tokens []dnfpvf.Token) int64 {
for _, token := range tokens {
if token.Kind == dnfpvf.TokenInt {
return token.Int
}
}
return 0
}
func allInts(tokens []dnfpvf.Token) []int64 {
values := make([]int64, 0, len(tokens))
for _, token := range tokens {
if token.Kind == dnfpvf.TokenInt {
values = append(values, token.Int)
}
}
return values
}
func firstText(tokens []dnfpvf.Token) string {
for _, token := range tokens {
if token.Kind == dnfpvf.TokenString {
return token.Value
}
}
return ""
}
func clampByte(value int64) int64 {
if value < 0 {
return 0
}
if value > 255 {
return 255
}
return value
}
func appendUnique(values []string, value string) []string {
for _, existing := range values {
if existing == value {
return values
}
}
return append(values, value)
}
@@ -0,0 +1,138 @@
package actionobject
import (
"testing"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
)
func parseTestScript(t *testing.T, path, text string) Script {
t.Helper()
document, err := dnfpvf.Parse(path, text)
if err != nil {
t.Fatalf("parse %s: %v", path, err)
}
script, err := Parse(document)
if err != nil {
t.Fatalf("decode %s: %v", path, err)
}
return script
}
// The gate object of 能源储藏室: it closes its own boss door while
// `storage gate open` is short of 2 and reopens it once both cylinders have
// counted in. Verbatim from
// passiveobject/actionobject/map/luke_raid/phase2/enegy_save/action/gate_control_gate.act.
const lukeEnergySaveGateScript = "" +
"[MOTION]\n[BASE ANI]\n`../`\n[/MOTION]\n" +
"[TRIGGER]\n[LIMIT]\n1\n[ENABLE]\n[ON]\n" +
"[DUNGEON SYMBOL COMPARE]\n`storage gate open`\n[<]\n2\n" +
"[DO BEHAVIOR]\n[ME]\n0\n[/TRIGGER]\n" +
"[BEHAVIOR]\n" +
"[NOTICE]\n`gate closed` 255 255 0 `None` 4000 1000\n[/NOTICE]\n" +
"[GATE ENABLE]\n-1 -1 0\n[DOWN]\n[/GATE ENABLE]\n" +
"[TRIGGER]\n1\n[ON]\n[TRIGGER]\n0\n[OFF]\n[/BEHAVIOR]\n" +
"[TRIGGER]\n[LIMIT]\n1\n[ENABLE]\n[OFF]\n" +
"[DUNGEON SYMBOL COMPARE]\n`storage gate open`\n[>=]\n2\n" +
"[DO BEHAVIOR]\n[ME]\n1\n[/TRIGGER]\n" +
"[BEHAVIOR]\n" +
"[NOTICE]\n`gate open` 255 255 0 `None` 4000 1000\n[/NOTICE]\n" +
"[GATE ENABLE]\n-1 -1 1\n[DOWN]\n[/GATE ENABLE]\n[/BEHAVIOR]\n"
// The left cylinder: it adds to the symbol once its room's monster is gone.
const lukeEnergySaveCylinderScript = "" +
"[MOTION]\n[BASE ANI]\n`../`\n[/MOTION]\n" +
"[TRIGGER]\n[LIMIT]\n1\n[WHICH]\n[MONSTER]\n[CHECKUP]\n" +
"[IS INDEX]\n57156\n[/IS INDEX]\n[CHECKED NO]\n[<]\n1\n[/CHECKUP]\n" +
"[DO BEHAVIOR]\n[ME]\n0\n[/TRIGGER]\n" +
"[BEHAVIOR]\n[DUNGEON SYMBOL CALC]\n`storage gate open`\n[+]\n1\n[/BEHAVIOR]\n"
func TestParseGateScriptDecodesBothBranches(t *testing.T) {
script := parseTestScript(t, "gate_control_gate.act", lukeEnergySaveGateScript)
if len(script.Triggers) != 2 || len(script.Behaviors) != 2 {
t.Fatalf("triggers=%d behaviors=%d want 2/2 (%+v)", len(script.Triggers), len(script.Behaviors), script)
}
if !script.Drives() {
t.Fatal("gate script does not drive anything")
}
closing := script.Triggers[0]
if closing.Limit != 1 || !closing.Enabled || closing.Behavior != 0 {
t.Fatalf("closing trigger=%+v", closing)
}
if len(closing.Conditions) != 1 ||
closing.Conditions[0] != (Condition{
Kind: ConditionDungeonSymbol, Symbol: "storage gate open", Op: CompareLess, Value: 2,
}) {
t.Fatalf("closing conditions=%+v", closing.Conditions)
}
if len(closing.Unsupported) != 0 {
t.Fatalf("closing trigger dropped %v", closing.Unsupported)
}
// The reopening trigger ships disabled and is switched on by behavior 0.
opening := script.Triggers[1]
if opening.Enabled || opening.Behavior != 1 {
t.Fatalf("opening trigger=%+v", opening)
}
if len(opening.Conditions) != 1 || opening.Conditions[0].Op != CompareGreaterEqual {
t.Fatalf("opening conditions=%+v", opening.Conditions)
}
closeBehavior := script.Behaviors[0]
if len(closeBehavior.Operations) != 4 {
t.Fatalf("close behavior operations=%+v", closeBehavior.Operations)
}
if got := closeBehavior.Operations[0]; got.Kind != OperationNotice ||
got.Text != "gate closed" || got.Color != 0xFFFFFF00 || got.DurationMS != 4000 {
t.Fatalf("notice=%+v", got)
}
if got := closeBehavior.Operations[1]; got.Kind != OperationGateEnable || got.GateEnabled ||
len(got.GateDirections) != 1 || got.GateDirections[0] != "down" {
t.Fatalf("gate close=%+v", got)
}
if got := closeBehavior.Operations[2]; got.Kind != OperationTriggerSwitch ||
got.TriggerIndex != 1 || !got.TriggerOn {
t.Fatalf("trigger switch on=%+v", got)
}
if got := closeBehavior.Operations[3]; got.Kind != OperationTriggerSwitch ||
got.TriggerIndex != 0 || got.TriggerOn {
t.Fatalf("trigger switch off=%+v", got)
}
openBehavior := script.Behaviors[1]
if len(openBehavior.Operations) != 2 {
t.Fatalf("open behavior operations=%+v", openBehavior.Operations)
}
if got := openBehavior.Operations[1]; got.Kind != OperationGateEnable || !got.GateEnabled {
t.Fatalf("gate open=%+v", got)
}
}
func TestParseCylinderScriptCountsTheRoomMonster(t *testing.T) {
script := parseTestScript(t, "gate_control_left.act", lukeEnergySaveCylinderScript)
if len(script.Triggers) != 1 || len(script.Behaviors) != 1 {
t.Fatalf("triggers=%d behaviors=%d want 1/1", len(script.Triggers), len(script.Behaviors))
}
trigger := script.Triggers[0]
if len(trigger.Conditions) != 1 ||
trigger.Conditions[0] != (Condition{
Kind: ConditionMonsterCount, MonsterID: 57156, Op: CompareLess, Value: 1,
}) {
t.Fatalf("conditions=%+v unsupported=%v", trigger.Conditions, trigger.Unsupported)
}
operations := script.Behaviors[0].Operations
if len(operations) != 1 ||
operations[0].Kind != OperationSymbolCalc ||
operations[0].Symbol != "storage gate open" ||
operations[0].Calc != "+" || operations[0].Value != 1 {
t.Fatalf("operations=%+v", operations)
}
}
func TestParseAnimationOnlyScriptDrivesNothing(t *testing.T) {
script := parseTestScript(t, "idle.act", "[MOTION]\n[BASE ANI]\n`../a.ani`\n[/MOTION]\n")
if script.Drives() {
t.Fatalf("animation-only script reported as driving: %+v", script)
}
}
@@ -0,0 +1,75 @@
package adventuregroup
import (
"math"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const (
DailyLoginStateMetadataKey = "adventure_group_daily_login"
RecommendedDungeonClearStatKey = "adventure_recommended_dungeon_clears"
ContentTypeRecommendedDungeon = 0
// ContentTypeCount is the number of u32 content counters in the native
// payload (raw offsets 16/32/24/28/36/40). It is NOT the profession row
// count: the 20 profession rows are ProfessionTypeCount below.
ContentTypeCount = 6
ProfessionTypeCount = 20
adventureGroupContentCountMax = math.MaxUint32
)
// Projection contains only account activity that has an authoritative local
// owner. Unsupported raid and PVP counters deliberately stay zero; the tower
// counters are filled by the tower owner (indexed by TowerType).
type Projection struct {
ConsecutiveLoginDays uint32
ContentCounts [ContentTypeCount]uint32
TowerRemaining [TowerTypeCount]uint32
TowerLimits [TowerTypeCount]uint32
// ProfessionCounts is what the native 20-row descriptor block carries: one
// per-character count for each profession classification, not a content
// counter. Row N is the classification returned by
// RuntimeConfig.ProfessionClassification.
ProfessionCounts [ProfessionTypeCount]uint16
Runtime RuntimeState
Representatives RepresentativeState
RepresentativesEnabled bool
// CapsuleGauge 是成长胶囊**一格**的容量(PVF `[capsule max exp]`),也就是
// 客户端那条进度条的分母。发出去的进度必须是"累计值 mod 这一格",否则
// 攒过 100% 之后客户端会算出 >100% 的数(或直接顶在 100% 不动)。
CapsuleGauge uint64
// CapsuleClaimCount 是"现在可以领取几次经验值",单位是客户端那条说明里的
// 10% 档(一格 = 10 次)。**它必须是一个不大的次数**:客户端会按它建列表
// 逐个拼字符串,见 adventuregroup.CapsuleClaimCount 的说明。
CapsuleClaimCount uint32
}
// Tower counters are indexed by these values in the adventure-group payload.
const (
TowerDespairType = iota
TowerGriefType
TowerTypeCount
)
// ProjectActivity folds durable character-scoped activity into the
// account-wide values consumed by the current adventure-group packet.
func ProjectActivity(
consecutiveLoginDays uint32,
characters []dnfrepo.CharacterRecord,
) Projection {
result := Projection{ConsecutiveLoginDays: consecutiveLoginDays}
var recommended uint64
for _, character := range characters {
value := character.Stats[RecommendedDungeonClearStatKey]
if value <= 0 {
continue
}
if uint64(value) >= adventureGroupContentCountMax-recommended {
recommended = adventureGroupContentCountMax
break
}
recommended += uint64(value)
}
result.ContentCounts[ContentTypeRecommendedDungeon] = uint32(recommended)
return result
}
@@ -0,0 +1,27 @@
package adventuregroup
import (
"math"
"testing"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
func TestProjectActivitySumsRecommendedDungeonClearsAndSaturates(t *testing.T) {
got := ProjectActivity(24, []dnfrepo.CharacterRecord{
{Stats: map[string]int64{RecommendedDungeonClearStatKey: 7}},
{Stats: map[string]int64{RecommendedDungeonClearStatKey: -1}},
{Stats: map[string]int64{RecommendedDungeonClearStatKey: math.MaxUint32}},
})
if got.ConsecutiveLoginDays != 24 {
t.Fatalf("consecutive days=%d, want 24", got.ConsecutiveLoginDays)
}
if got.ContentCounts[ContentTypeRecommendedDungeon] != math.MaxUint32 {
t.Fatalf("recommended clears=%d, want %d", got.ContentCounts[ContentTypeRecommendedDungeon], math.MaxUint32)
}
for index := 1; index < ContentTypeCount; index++ {
if got.ContentCounts[index] != 0 {
t.Fatalf("unsupported content type %d=%d, want 0", index, got.ContentCounts[index])
}
}
}
@@ -0,0 +1,186 @@
package adventuregroup
import (
"context"
"errors"
"fmt"
"math"
"strings"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
var ErrCreditAccountNotFound = errors.New("adventure-group credit account is missing")
// ExperienceCredit 描述"一次已经发给角色的经验"该怎么进账号级冒险团账本。
//
// 口径(2026-09-24 服主确认,此前只有满级经验才记账):
//
// - 骁勇结晶:**任意等级**获得的经验都按 PVF `[exp to get shop point]` 累计
// (真值 1500 万 = 1 个,封顶 `[max shop point]` = 250)。角色自己的升级
// 经验完全不受影响 —— 这里只是把同一笔经验**镜像**一份进账号账本,
// "获得多少经验就累计多少"。
// - 成长胶囊槽 / 名誉经验:**满级专属**。客户端自己的说明写着
// "Lv90以上的角色获得经验值时,经验值会累积在成长胶囊槽中",
// 名誉等级同样是满级之后的系统,所以只有越过等级上限的那部分进这两个账本。
//
// 三个账本共用一个数值来源,是为了让同一笔结算永远算出互相一致的账号状态;
// 现在两个数值来源必须一起给出,免得调用方只填一个而悄悄改了别人的口径。
type ExperienceCredit struct {
// AwardedExperience 是这次**真正发给角色**的经验(含成长契约 / 装备 /
// 疲劳蓄电池 / 任务连续性补足等加成),进骁勇结晶。
AwardedExperience uint64
// MaximumLevelExperience 是其中**越过等级上限**的那部分,进成长胶囊槽与
// 名誉经验。它永远不该大于 AwardedExperience,Normalize 会兜住。
MaximumLevelExperience uint64
}
// Normalize 收敛两个数值的关系:满级部分不可能多于总经验。
func (c ExperienceCredit) Normalize() ExperienceCredit {
if c.MaximumLevelExperience > c.AwardedExperience {
c.MaximumLevelExperience = c.AwardedExperience
}
return c
}
// Empty 表示这笔经验什么都不用记(零经验或重放)。
func (c ExperienceCredit) Empty() bool {
return c.AwardedExperience == 0 && c.MaximumLevelExperience == 0
}
// ExperienceCreditResult 汇报记入之后的账号级数值,便于日志与测试核对。
type ExperienceCreditResult struct {
AccountID string
AwardedExperience uint64
MaximumLevelExperience uint64
BravePoints uint32
BraveExperience uint64
CapsuleExperience uint64
HonorExperience uint64
// GrowthLevel/GrowthExperience are the adventure-group level ledger after
// this credit, for logging and tests. A credit that carries no creditable
// level experience (a max-level character, or a zero-exp replay) is a no-op
// and reports zero here without touching the stored ledger.
GrowthLevel int
GrowthExperience uint64
}
// ApplyExperienceCredit 在**调用方已经加载、并即将在自己的事务里保存**的账号
// 记录上记入一笔经验:
//
// - 骁勇结晶:任意等级的经验都算,按 PVF `[exp to get shop point]` 换算,
// 封顶 `[max shop point]`;
// - 成长胶囊槽:只算满级溢出部分,封顶 `[capsule max exp] × [capsule max count]`;
// - 账号名誉经验 `accounts.honor_exp`:只算满级溢出部分(名誉等级是满级专属)。
//
// 调用方负责保存;本函数不碰仓库,因此不会和调用方已做的账号改动互相覆盖。
func ApplyExperienceCredit(
account *dnfrepo.AccountRecord,
config RuntimeConfig,
credit ExperienceCredit,
now time.Time,
) (ExperienceCreditResult, error) {
if account == nil {
return ExperienceCreditResult{}, ErrOwnerUnavailable
}
accountID := strings.TrimSpace(account.AccountID)
if accountID == "" {
return ExperienceCreditResult{}, ErrAccountRequired
}
credit = credit.Normalize()
if credit.Empty() {
return ExperienceCreditResult{AccountID: accountID}, nil
}
if now.IsZero() {
now = time.Now().UTC()
}
state, err := ParseRuntimeState(*account, config, now)
if err != nil {
return ExperienceCreditResult{}, err
}
state.AddExperience(config, credit.AwardedExperience, credit.MaximumLevelExperience)
// 测试口径:满级角色获得的经验也计入冒险团等级账本,验证冒险团经验
// 是否正常增长;原始规则原本只计算 AwardedExperience - MaximumLevelExperience。
levelGain := credit.AwardedExperience
growth, err := ApplyGrowthExperience(account, config.Growth, Summary{}, uint32(min(levelGain, math.MaxUint32)), now)
if err != nil {
return ExperienceCreditResult{}, err
}
encoded, err := state.Marshal()
if err != nil {
return ExperienceCreditResult{}, err
}
if account.Metadata == nil {
account.Metadata = make(map[string]string)
}
account.Metadata[RuntimeStateMetadataKey] = encoded
// 名誉经验只收满级溢出:未满级经验不进名誉面板,这是服主确认的口径。
if credit.MaximumLevelExperience > 0 {
if account.HonorExp > math.MaxUint64-credit.MaximumLevelExperience {
account.HonorExp = math.MaxUint64
} else {
account.HonorExp += credit.MaximumLevelExperience
}
}
account.UpdatedAt = now
return ExperienceCreditResult{
AccountID: accountID,
AwardedExperience: credit.AwardedExperience,
MaximumLevelExperience: credit.MaximumLevelExperience,
BravePoints: state.ShopPoints[ShopPointBrave],
BraveExperience: state.BraveExperience,
CapsuleExperience: state.GrowthExperience,
HonorExperience: account.HonorExp,
GrowthLevel: growth.Level,
GrowthExperience: growth.Experience,
}, nil
}
// CreditExperience 是 ApplyExperienceCredit 的读-改-写版本:在**调用方已经
// 打开的事务**里加载账号、记入经验并保存。
//
// 怪物击杀、任务完成这些路径平时只碰角色/技能两张表,没有账号记录在手,
// 用这个版本就能把账号级冒险团状态并进同一个事务提交,而不是事后再补一次
// 可能失败的写(补写失败会让"经验涨了结晶没涨"这种状态静默留存)。
func CreditExperience(
ctx context.Context,
accounts dnfrepo.AccountRepository,
config RuntimeConfig,
accountID string,
credit ExperienceCredit,
now time.Time,
) (ExperienceCreditResult, error) {
if accounts == nil {
return ExperienceCreditResult{}, ErrOwnerUnavailable
}
accountID = strings.TrimSpace(accountID)
if accountID == "" {
return ExperienceCreditResult{}, ErrAccountRequired
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return ExperienceCreditResult{}, err
}
if credit.Normalize().Empty() {
return ExperienceCreditResult{AccountID: accountID}, nil
}
account, found, err := accounts.Load(ctx, accountID)
if err != nil {
return ExperienceCreditResult{}, fmt.Errorf("load adventure-group credit account: %w", err)
}
if !found || strings.TrimSpace(account.AccountID) != accountID {
return ExperienceCreditResult{}, fmt.Errorf("%w: account=%s", ErrCreditAccountNotFound, accountID)
}
account = dnfrepo.CloneAccount(account)
result, err := ApplyExperienceCredit(&account, config, credit, now)
if err != nil {
return ExperienceCreditResult{}, err
}
if err := accounts.Save(ctx, account); err != nil {
return ExperienceCreditResult{}, fmt.Errorf("save adventure-group credit account: %w", err)
}
return result, nil
}
@@ -0,0 +1,367 @@
package adventuregroup
import (
"context"
"errors"
"testing"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func creditTestConfig() RuntimeConfig {
return RuntimeConfig{
ShopCategories: []ShopCategory{
{
Index: ShopPointBrave,
MaxPoint: 250,
ExperiencePerPoint: 15_000_000,
PointPerExperience: 1,
},
{Index: ShopPointGlory, MaxPoint: 9999, ResetPointMonthly: true},
{Index: ShopPointPure},
},
Capsule: CapsuleConfig{
MinimumExperience: 11_691_495,
MaximumExperience: 116_914_950,
MaximumCount: 10,
MinimumLevel: 50,
MaximumLevel: 86,
GrantedExperience: 1_000_000,
},
}
}
func creditTestRepositories(t *testing.T, account dnfrepo.AccountRecord) dnfrepo.Group {
t.Helper()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Account.Save(context.Background(), account); err != nil {
t.Fatal(err)
}
return repositories
}
// 一笔**满级**经验必须同时推进三个账号级累计量,余数还要逐笔累积 ——
// 这正是"怪物击杀也要给结晶"那条改动的核心口径。
func TestCreditExperienceAccruesCrystalsCapsuleAndHonorExpForMaxLevelAward(t *testing.T) {
config := creditTestConfig()
now := time.Date(2026, time.September, 23, 12, 0, 0, 0, time.UTC)
repositories := creditTestRepositories(t, dnfrepo.AccountRecord{AccountID: "dnf:1"})
// 满级角色的整笔经验都属于"越过上限"的部分。
atCap := func(value uint64) ExperienceCredit {
return ExperienceCredit{AwardedExperience: value, MaximumLevelExperience: value}
}
credit, err := CreditExperience(context.Background(), repositories.Account, config, "dnf:1", atCap(10_000_000), now)
if err != nil {
t.Fatal(err)
}
if credit.AwardedExperience != 10_000_000 || credit.BravePoints != 0 || credit.BraveExperience != 10_000_000 ||
credit.CapsuleExperience != 10_000_000 || credit.HonorExperience != 10_000_000 {
t.Fatalf("first credit=%+v", credit)
}
// 第二笔把余数推过 1500 万:结晶 +1,余数留 5,000,000。
credit, err = CreditExperience(context.Background(), repositories.Account, config, "dnf:1", atCap(10_000_000), now)
if err != nil {
t.Fatal(err)
}
if credit.BravePoints != 1 || credit.BraveExperience != 5_000_000 || credit.HonorExperience != 20_000_000 {
t.Fatalf("second credit=%+v", credit)
}
// 持久化结果必须与返回值一致(账号元数据 + honor_exp 同事务落库)。
account, found, err := repositories.Account.Load(context.Background(), "dnf:1")
if err != nil || !found {
t.Fatalf("account found=%t err=%v", found, err)
}
state, err := ParseRuntimeState(account, config, now)
if err != nil {
t.Fatal(err)
}
if state.ShopPoints[ShopPointBrave] != 1 || state.BraveExperience != 5_000_000 ||
state.GrowthExperience != 20_000_000 || account.HonorExp != 20_000_000 {
t.Fatalf("persisted state=%+v honor=%d", state, account.HonorExp)
}
}
// 2026-09-24 服主口径:**没满级的角色也要能攒骁勇结晶**。
// 未满级经验 1:1 进结晶(不影响角色自己的升级经验),但**不进**成长胶囊槽、
// **不进**名誉经验 —— 胶囊槽与名誉等级都是满级专属。
func TestCreditExperienceSubCapFeedsOnlyCrystals(t *testing.T) {
config := creditTestConfig()
now := time.Date(2026, time.September, 24, 1, 0, 0, 0, time.UTC)
repositories := creditTestRepositories(t, dnfrepo.AccountRecord{AccountID: "dnf:1"})
// 一次 40 级的打怪:全额经验,没有满级部分。
credit, err := CreditExperience(
context.Background(),
repositories.Account,
config,
"dnf:1",
ExperienceCredit{AwardedExperience: 20_000_000},
now,
)
if err != nil {
t.Fatal(err)
}
if credit.BravePoints != 1 || credit.BraveExperience != 5_000_000 {
t.Fatalf("sub-cap crystals=%+v", credit)
}
if credit.CapsuleExperience != 0 {
t.Fatalf("sub-cap experience leaked into the capsule slot: %+v", credit)
}
if credit.HonorExperience != 0 {
t.Fatalf("sub-cap experience leaked into honor experience: %+v", credit)
}
// 再攒一笔把余数推过 1500 万:结晶 +1。
credit, err = CreditExperience(
context.Background(),
repositories.Account,
config,
"dnf:1",
ExperienceCredit{AwardedExperience: 10_000_000},
now,
)
if err != nil {
t.Fatal(err)
}
if credit.BravePoints != 2 || credit.BraveExperience != 0 {
t.Fatalf("second sub-cap credit=%+v", credit)
}
account, _, err := repositories.Account.Load(context.Background(), "dnf:1")
if err != nil {
t.Fatal(err)
}
state, err := ParseRuntimeState(account, config, now)
if err != nil {
t.Fatal(err)
}
if state.GrowthExperience != 0 || account.HonorExp != 0 {
t.Fatalf("sub-cap credits touched capsule/honor: state=%+v honor=%d", state, account.HonorExp)
}
}
// 一笔经验里"满级部分"不可能多于总额:Normalize 必须兜住调用方填错的顺序。
func TestExperienceCreditNormalizeClampsMaximumToAwarded(t *testing.T) {
credit := ExperienceCredit{AwardedExperience: 100, MaximumLevelExperience: 500}.Normalize()
if credit.MaximumLevelExperience != 100 {
t.Fatalf("normalized=%+v", credit)
}
if (ExperienceCredit{}).Empty() != true || (ExperienceCredit{AwardedExperience: 1}).Empty() != false {
t.Fatalf("Empty() semantics changed")
}
}
// 骁勇结晶封顶 [max shop point]:超过上限的经验只留在余数里,不再加结晶。
func TestCreditExperienceStopsAtShopPointCap(t *testing.T) {
config := creditTestConfig()
now := time.Date(2026, time.September, 23, 12, 0, 0, 0, time.UTC)
repositories := creditTestRepositories(t, dnfrepo.AccountRecord{AccountID: "dnf:1"})
if _, err := CreditExperience(
context.Background(), repositories.Account, config, "dnf:1",
ExperienceCredit{AwardedExperience: 250 * 15_000_000, MaximumLevelExperience: 250 * 15_000_000}, now,
); err != nil {
t.Fatal(err)
}
credit, err := CreditExperience(
context.Background(), repositories.Account, config, "dnf:1",
ExperienceCredit{AwardedExperience: 15_000_000, MaximumLevelExperience: 15_000_000}, now,
)
if err != nil {
t.Fatal(err)
}
// 已到上限:结晶不再增加,超出的整笔经验按现有口径丢弃(余数仍是整除后的 0)。
if credit.BravePoints != 250 || credit.BraveExperience != 0 {
t.Fatalf("capped credit=%+v", credit)
}
}
// 成长胶囊槽按**总容量**封顶:一格 = [capsule max exp](100%),可以攒
// [capsule max count] 格(界面写"累积数上限 : 10个")。所以一笔远超一格的
// 经验必须继续累加、绝不在一格处停住 —— 那正是"胶囊不累计"的根因。
func TestCreditExperienceKeepsCapsuleGrowingPastOneGauge(t *testing.T) {
config := creditTestConfig()
now := time.Date(2026, time.September, 23, 12, 0, 0, 0, time.UTC)
repositories := creditTestRepositories(t, dnfrepo.AccountRecord{AccountID: "dnf:1"})
credit, err := CreditExperience(
context.Background(), repositories.Account, config, "dnf:1",
ExperienceCredit{AwardedExperience: 200_000_000, MaximumLevelExperience: 200_000_000}, now,
)
if err != nil {
t.Fatal(err)
}
if credit.BravePoints != 13 || credit.BraveExperience != 5_000_000 ||
credit.CapsuleExperience != 200_000_000 {
t.Fatalf("credit=%+v", credit)
}
// 再攒到超过总容量:胶囊停在 总容量 = 单格 × 格数,结晶继续按同一笔走。
credit, err = CreditExperience(
context.Background(), repositories.Account, config, "dnf:1",
ExperienceCredit{AwardedExperience: 2_000_000_000, MaximumLevelExperience: 2_000_000_000}, now,
)
if err != nil {
t.Fatal(err)
}
if want := CapsuleTotalCapacity(config.Capsule); credit.CapsuleExperience != want {
t.Fatalf("capsule=%d want total capacity %d", credit.CapsuleExperience, want)
}
if want := config.Capsule.MaximumExperience * uint64(config.Capsule.MaximumCount); want == config.Capsule.MaximumExperience {
t.Fatalf("test config cannot tell one gauge from the total: %d", want)
}
}
// 成长胶囊"可获得的经验值"是**可领取次数**,不是经验值总量:
// 客户端拿到这个值会建同样多条列表项(0x13F0FA0 的 `for (i=0; i<count; i++)`),
// 所以上限必须是"格数 × 每格档数"(10 × 10 = 100),而不是 1,000 万级的总量。
func TestCapsuleClaimCountIsBoundedByTotalCapacityInChunks(t *testing.T) {
config := creditTestConfig().Capsule
step := config.MinimumExperience
perGauge := config.MaximumExperience / step
if perGauge != 10 {
t.Fatalf("PVF ratio max/step = %d, want 10 (10%% per claim)", perGauge)
}
cases := []struct {
name string
accumulated uint64
want uint32
}{
{name: "没到一档", accumulated: step - 1, want: 0},
{name: "正好一档", accumulated: step, want: 1},
{name: "一格", accumulated: config.MaximumExperience, want: uint32(perGauge)},
{name: "一格多一档", accumulated: config.MaximumExperience + step, want: uint32(perGauge) + 1},
{name: "满总容量", accumulated: CapsuleTotalCapacity(config), want: uint32(perGauge) * config.MaximumCount},
{name: "超过总容量也不放大", accumulated: ^uint64(0), want: uint32(perGauge) * config.MaximumCount},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
if got := CapsuleClaimCount(config, testCase.accumulated); got != testCase.want {
t.Fatalf("CapsuleClaimCount(%d)=%d want=%d", testCase.accumulated, got, testCase.want)
}
})
}
if got := CapsuleClaimCount(CapsuleConfig{}, 1_000_000); got != 0 {
t.Fatalf("missing PVF claim count = %d, want 0", got)
}
}
// 没有可记的经验时不许写账号(否则每次零经验击杀都会刷一次账号行)。
func TestCreditExperienceIgnoresZeroValue(t *testing.T) {
config := creditTestConfig()
repositories := creditTestRepositories(t, dnfrepo.AccountRecord{AccountID: "dnf:1"})
credit, err := CreditExperience(context.Background(), repositories.Account, config, "dnf:1", ExperienceCredit{}, time.Now().UTC())
if err != nil {
t.Fatal(err)
}
if credit.AwardedExperience != 0 || credit.HonorExperience != 0 {
t.Fatalf("zero credit=%+v", credit)
}
account, _, err := repositories.Account.Load(context.Background(), "dnf:1")
if err != nil {
t.Fatal(err)
}
if account.Metadata[RuntimeStateMetadataKey] != "" || account.HonorExp != 0 {
t.Fatalf("zero credit wrote account=%+v", account)
}
}
// 账号不存在必须整笔失败:静默跳过会让"经验涨了、结晶没涨"留下来。
func TestCreditExperienceRequiresTheAccount(t *testing.T) {
repositories := dnfrepomemory.NewMemoryGroup()
_, err := CreditExperience(
context.Background(), repositories.Account, creditTestConfig(), "dnf:missing",
ExperienceCredit{AwardedExperience: 10}, time.Now().UTC(),
)
if !errors.Is(err, ErrCreditAccountNotFound) {
t.Fatalf("err=%v", err)
}
}
// ApplyExperienceCredit 是给"账号已经在调用方手里"的路径用的
// (地下城通关结算):它只改内存记录,落库由调用方自己的事务负责。
func TestApplyExperienceCreditMutatesOnlyTheGivenAccountRecord(t *testing.T) {
config := creditTestConfig()
now := time.Date(2026, time.September, 23, 12, 0, 0, 0, time.UTC)
account := dnfrepo.AccountRecord{AccountID: "dnf:1", Metadata: map[string]string{"keep": "1"}}
credit, err := ApplyExperienceCredit(&account, config, ExperienceCredit{
AwardedExperience: 15_000_000,
MaximumLevelExperience: 15_000_000,
}, now)
if err != nil {
t.Fatal(err)
}
if credit.BravePoints != 1 || credit.HonorExperience != 15_000_000 || account.Metadata["keep"] != "1" {
t.Fatalf("credit=%+v account=%+v", credit, account)
}
}
// 2026-09-29 服主口径:**满级角色获得的经验不再累加进冒险团(等级)经验**,
// 满级之前照旧累加。同一笔账里的骁勇结晶 / 成长胶囊槽 / 名誉经验不受影响——
// 那三条各自有自己的口径(结晶任意等级、胶囊与名誉满级专属)。
//
// 回归背景:等级账本过去和结晶共用同一个 AwardedExperience,满级角色打怪
// 一直在推进冒险团等级(线上日志 adventure_group_credit=454 new_level=90)。
func TestCreditExperienceKeepsLevelLedgerForSubCapExperienceOnly(t *testing.T) {
config := creditTestConfig()
config.Growth = GrowthConfig{
MaximumLevel: 3,
RateParts: growthRateScale, // 1.0,增益与冒险团经验 1:1
RequiredExperience: []uint64{0, 0, 100, 200},
LevelBenefits: make([]Benefits, 4),
}
now := time.Date(2026, time.September, 29, 4, 0, 0, 0, time.UTC)
repositories := creditTestRepositories(t, dnfrepo.AccountRecord{AccountID: "dnf:1"})
// 1) 满级角色:整笔都是"越过上限"的部分 → 结晶/名誉照涨,等级账本一点不动。
credit, err := CreditExperience(
context.Background(), repositories.Account, config, "dnf:1",
ExperienceCredit{AwardedExperience: 15_000_000, MaximumLevelExperience: 15_000_000},
now,
)
if err != nil {
t.Fatal(err)
}
if credit.BravePoints != 1 || credit.HonorExperience != 15_000_000 {
t.Fatalf("满级笔照样要进结晶与名誉: %+v", credit)
}
// 等级账本必须一个字都没写(ApplyGrowthExperience 在 gain==0 时直接返回,
// 既不生成元数据也不报告等级)。
afterMaxLevel, found, err := repositories.Account.Load(context.Background(), "dnf:1")
if err != nil || !found {
t.Fatalf("account found=%t err=%v", found, err)
}
if value := afterMaxLevel.Metadata[GrowthMetadataKey]; value != "" {
t.Fatalf("满级经验不该推进冒险团等级,却写了账本: %s", value)
}
// 2) 未满级角色:全额进等级账本(150 → 2 级 + 50 余量),等级口径照旧。
credit, err = CreditExperience(
context.Background(), repositories.Account, config, "dnf:1",
ExperienceCredit{AwardedExperience: 150},
now,
)
if err != nil {
t.Fatal(err)
}
if credit.GrowthLevel != 2 || credit.GrowthExperience != 50 {
t.Fatalf("满级前的经验必须照旧累加: level=%d exp=%d", credit.GrowthLevel, credit.GrowthExperience)
}
// 持久化的账本必须与返回值一致。
account, found, err := repositories.Account.Load(context.Background(), "dnf:1")
if err != nil || !found {
t.Fatalf("account found=%t err=%v", found, err)
}
state, err := readGrowthState(account, config.Growth, Summary{})
if err != nil {
t.Fatal(err)
}
if state.Level != 2 || state.Experience != 50 {
t.Fatalf("persisted growth=%+v", state)
}
}
@@ -0,0 +1,83 @@
package adventuregroup
import (
"context"
"os"
"testing"
platformpvf "longheng.io/server/internal/platform/pvf"
)
func TestExpeditionRewardNativeRoundingAndPartyAttributes(t *testing.T) {
c := RuntimeConfig{
ExpeditionAreas: []ExpeditionArea{{Index: 1, RewardRates: map[uint32]float64{21600: 3.5}}},
PartyMemberRewardRates: []float64{1, 1, 2, 3, 4}, ExpeditionExtraRate: 0.5,
AttributeRewardRates: []float64{1, 1, 1.4, 1.8, 2.2},
AttributeIDs: map[string]byte{"a": 1, "b": 2},
CharacterAttributes: map[[2]byte][]string{{0, 2}: {"a"}, {1, 2}: {"b"}},
}
for _, test := range []struct {
name string
members []ExpeditionMemberInput
attributes []byte
want uint32
}{
{"round_before_extra", []ExpeditionMemberInput{{Level: 90, Job: 0, GrowType: 0x22}}, nil, 2},
{"level_is_not_multiplier", []ExpeditionMemberInput{{Level: 100, Job: 0, GrowType: 0x22}}, nil, 2},
{"two_members_one_attribute", []ExpeditionMemberInput{{Level: 90, Job: 0, GrowType: 2}, {Level: 90, Job: 0, GrowType: 2}}, []byte{1, 2}, 3},
{"two_members_two_attributes", []ExpeditionMemberInput{{Level: 90, Job: 0, GrowType: 2}, {Level: 90, Job: 1, GrowType: 0x22}}, []byte{1, 2}, 5},
} {
t.Run(test.name, func(t *testing.T) {
got, ok := c.ExpeditionReward(1, 21600, test.members, test.attributes)
if !ok || got != test.want {
t.Fatalf("reward=%d/%t want=%d", got, ok, test.want)
}
})
}
if _, ok := c.ExpeditionReward(1, 21600, nil, nil); ok {
t.Fatal("empty party accepted")
}
if _, ok := c.ExpeditionReward(1, 1, []ExpeditionMemberInput{{Level: 90}}, nil); ok {
t.Fatal("unsupported duration accepted")
}
clone := c.Clone()
clone.PartyMemberRewardRates[1] = 7
if c.PartyMemberRewardRates[1] != 1 {
t.Fatal("clone changed original party rates")
}
}
func TestRealPVFExpeditionRewardsMatchNativePreview(t *testing.T) {
path := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if path == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: path})
if err != nil {
t.Fatal(err)
}
tables, err := LoadComplete(context.Background(), archive)
if err != nil {
t.Fatal(err)
}
c := tables.Runtime()
if c.ExpeditionExtraRate != 0.5 || len(c.PartyMemberRewardRates) != 5 {
t.Fatalf("native bonus config=%+v", c)
}
want := [4][4]uint32{{1, 2, 3, 4}, {1, 2, 3, 5}, {1, 2, 4, 6}, {1, 3, 5, 6}}
for _, area := range c.ExpeditionAreas {
for timeIndex, hours := range []uint32{6, 12, 18, 24} {
for count := 1; count <= 4; count++ {
members := make([]ExpeditionMemberInput, count)
for i := range members {
members[i] = ExpeditionMemberInput{Level: 90, Job: 0, GrowType: 2}
}
got, ok := c.ExpeditionReward(area.Index, hours*3600, members, nil)
if !ok || got != want[area.Index-1][timeIndex] {
t.Fatalf("area=%d hours=%d members=%d reward=%d/%t", area.Index, hours, count, got, ok)
}
t.Logf("NATIVE_CASE rate=%g party=%g match=1 extra=%g duration=%d count=%d reward=%d", area.RewardRates[hours*3600], c.PartyMemberRewardRates[count], c.ExpeditionExtraRate, hours*3600, count, got)
}
}
}
}
@@ -0,0 +1,220 @@
package adventuregroup
import (
"encoding/json"
"fmt"
"math"
"math/bits"
"strconv"
"strings"
"time"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// GrowthMetadataKey is the account-owned adventure-group growth ledger. It is
// separate from the runtime state (shop points / capsule) because its level is
// what the client renders as the adventure-group level.
const GrowthMetadataKey = "adventure_group_growth_v1"
const growthRateScale = uint64(1_000_000)
// GrowthConfig is the PVF `[max adventure level]` / `[adventure exp rate]` /
// `[adventure exp table]` / per-level `[stat]` benefits.
type GrowthConfig struct {
MaximumLevel int
RateParts uint64
// Entry N is the experience needed to enter level N, not a cumulative total.
RequiredExperience []uint64
LevelBenefits []Benefits
}
type growthState struct {
Level int `json:"level"`
Experience uint64 `json:"experience"`
Remainder uint64 `json:"remainder"`
}
func parseGrowthConfig(doc *dnfpvf.Document) (GrowthConfig, error) {
c := GrowthConfig{MaximumLevel: int(firstSectionInt(doc, "max adventure level"))}
if c.MaximumLevel < 1 || c.MaximumLevel > 255 {
return c, fmt.Errorf("%w: adventure maximum level", ErrTableMalformed)
}
tokens, _ := doc.Section("adventure exp rate")
if len(tokens) != 1 || tokenNumber(tokens[0]) <= 0 || tokenNumber(tokens[0]) > 1 {
return c, fmt.Errorf("%w: adventure experience rate", ErrTableMalformed)
}
c.RateParts = uint64(math.Round(tokenNumber(tokens[0]) * float64(growthRateScale)))
c.RequiredExperience = make([]uint64, c.MaximumLevel+1)
c.LevelBenefits = make([]Benefits, c.MaximumLevel+1)
tokens, _ = doc.Section("adventure exp table")
seen := make(map[int]bool)
if len(tokens)%2 != 0 {
return c, fmt.Errorf("%w: adventure experience rows", ErrTableMalformed)
}
for i := 0; i < len(tokens); i += 2 {
level := int(tokens[i].Int)
value, err := strconv.ParseUint(tokens[i+1].Value, 10, 64)
if tokens[i].Kind != dnfpvf.TokenInt || tokens[i+1].Kind != dnfpvf.TokenString || err != nil || level < 1 || seen[level] {
return c, fmt.Errorf("%w: adventure experience row %d", ErrTableMalformed, i/2)
}
seen[level] = true
if level <= c.MaximumLevel {
c.RequiredExperience[level] = value
}
}
for level := 1; level <= c.MaximumLevel; level++ {
if !seen[level] || (level > 1 && c.RequiredExperience[level] == 0) {
return c, fmt.Errorf("%w: adventure experience level %d", ErrTableMalformed, level)
}
}
level := 0
for _, section := range doc.Sections {
values := sectionTokens(doc, section)
switch normalizedPVFName(section.Name) {
case "attribute":
level = int(firstInt(values))
case "/attribute":
level = 0
case "stat", "grow":
if level < 1 || level > c.MaximumLevel || len(values) < 3 {
continue
}
value := tokenNumber(values[2])
if value < 0 || value > math.MaxUint32 {
return c, fmt.Errorf("%w: adventure benefit level %d", ErrTableMalformed, level)
}
switch values[1].Value {
case "str":
c.LevelBenefits[level].ManageOption = uint64(value)
case "clear result exp up":
c.LevelBenefits[level].ExpBonusPercent = uint64(value)
}
}
}
return c, nil
}
// CurrentSummary returns the account's adventure-group level. Once the first
// earned experience is committed the growth ledger owns it; before that the
// legacy roster-derived level is migrated into the ledger so a roster change
// cannot lower an established account.
func (t *Tables) CurrentSummary(account dnfrepo.AccountRecord, characters []Character) (Summary, error) {
legacy, err := t.Calculate(characters)
if err != nil || t.runtime.Growth.MaximumLevel == 0 {
return legacy, err
}
c := t.runtime.Growth
initial := growthState{Level: max(1, min(legacy.ManageLevel, c.MaximumLevel))}
if legacy.ManageLevel > 0 && legacy.ManageLevel < len(t.manageThresholds) && initial.Level < c.MaximumLevel {
lower, upper := t.manageThresholds[legacy.ManageLevel-1], t.manageThresholds[legacy.ManageLevel]
if upper > lower {
hi, lo := bits.Mul64(legacy.TotalPoint-lower, c.RequiredExperience[initial.Level+1])
initial.Experience, _ = bits.Div64(hi, lo, upper-lower)
}
}
state, err := readGrowthState(account, c, Summary{ManageLevel: initial.Level, TotalPoint: initial.Experience})
if err != nil {
return Summary{}, err
}
return Summary{ManageLevel: state.Level, TotalPoint: state.Experience, Benefits: c.LevelBenefits[state.Level]}, nil
}
func readGrowthState(account dnfrepo.AccountRecord, c GrowthConfig, initial Summary) (growthState, error) {
state := growthState{Level: max(1, initial.ManageLevel), Experience: initial.TotalPoint}
if value := strings.TrimSpace(account.Metadata[GrowthMetadataKey]); value != "" {
if err := json.Unmarshal([]byte(value), &state); err != nil {
return state, fmt.Errorf("%w: growth: %v", ErrRuntimeStateInvalid, err)
}
}
if state.Level < 1 || state.Level > c.MaximumLevel || state.Remainder >= growthRateScale ||
(state.Level < c.MaximumLevel && state.Experience >= c.RequiredExperience[state.Level+1]) {
return state, fmt.Errorf("%w: growth level/progress", ErrRuntimeStateInvalid)
}
return state, nil
}
// SeedGrowthState migrates the roster-derived legacy level into the growth
// ledger the first time an account is read with both the account record and
// its roster in hand. Until the ledger exists, CurrentSummary can only show the
// migrated value without persisting it; seeding at login means a later
// experience grant can never start the ledger below an established level.
func SeedGrowthState(account *dnfrepo.AccountRecord, c GrowthConfig, legacy Summary, now time.Time) (bool, error) {
if account == nil || c.MaximumLevel == 0 {
return false, nil
}
if strings.TrimSpace(account.Metadata[GrowthMetadataKey]) != "" {
return false, nil
}
if account.Metadata == nil {
return false, fmt.Errorf("%w: account metadata not loaded", ErrRuntimeStateInvalid)
}
state := growthState{Level: max(1, min(legacy.ManageLevel, c.MaximumLevel)), Experience: legacy.TotalPoint}
if state.Level < c.MaximumLevel && state.Experience >= c.RequiredExperience[state.Level+1] {
state.Experience = 0
}
if state.Level > 1 && state.Level < len(c.RequiredExperience) && c.RequiredExperience[state.Level] > 0 {
// A level carried over from the roster starts its bar from zero; the
// roster's total point is a different scale and must not be reused.
state.Experience = 0
}
encoded, err := json.Marshal(state)
if err != nil {
return false, err
}
account.Metadata[GrowthMetadataKey] = string(encoded)
account.UpdatedAt = now
return true, nil
}
// ApplyGrowthExperience records one earned-experience grant on an account the
// caller has already loaded and is about to save itself. It mirrors the
// ApplyExperienceCredit contract (no repository access), which is what keeps a
// single settlement from double-counting the same experience.
//
// gain is the character experience that was actually awarded. RateParts turns
// it into adventure-group experience; the remainder is carried so many small
// grants still add up to the same total as one large one.
func ApplyGrowthExperience(
account *dnfrepo.AccountRecord,
config GrowthConfig,
initial Summary,
gain uint32,
now time.Time,
) (growthState, error) {
c := config
if account == nil {
return growthState{}, ErrOwnerUnavailable
}
if c.MaximumLevel == 0 || gain == 0 {
return growthState{}, nil
}
if account.Metadata == nil {
return growthState{}, fmt.Errorf("%w: account metadata not loaded", ErrRuntimeStateInvalid)
}
state, err := readGrowthState(*account, c, initial)
if err != nil {
return growthState{}, err
}
scaled := uint64(gain)*c.RateParts + state.Remainder
remaining := scaled / growthRateScale
state.Remainder = scaled % growthRateScale
for state.Level < c.MaximumLevel && remaining >= c.RequiredExperience[state.Level+1]-state.Experience {
remaining -= c.RequiredExperience[state.Level+1] - state.Experience
state.Level++
state.Experience = 0
}
if state.Level == c.MaximumLevel {
state.Experience, state.Remainder = 0, 0
} else {
state.Experience += remaining
}
encoded, err := json.Marshal(state)
if err != nil {
return state, err
}
account.Metadata[GrowthMetadataKey] = string(encoded)
account.UpdatedAt = now
return state, nil
}
@@ -0,0 +1,99 @@
package adventuregroup
import (
"testing"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
func growthTestConfig() GrowthConfig {
// Three levels; entering level 2 costs 100, level 3 costs 200.
return GrowthConfig{
MaximumLevel: 3,
RateParts: growthRateScale, // 1:1, so gains map straight through
RequiredExperience: []uint64{0, 0, 100, 200},
LevelBenefits: make([]Benefits, 4),
}
}
func TestApplyGrowthExperienceLevelsAndCarriesRemainder(t *testing.T) {
c := growthTestConfig()
account := dnfrepo.AccountRecord{AccountID: "a", Metadata: map[string]string{}}
now := time.Unix(1_800_000_000, 0)
state, err := ApplyGrowthExperience(&account, c, Summary{}, 60, now)
if err != nil {
t.Fatal(err)
}
if state.Level != 1 || state.Experience != 60 {
t.Fatalf("first grant level=%d exp=%d", state.Level, state.Experience)
}
// 60 + 60 crosses the 100 needed for level 2 with 20 left over.
state, err = ApplyGrowthExperience(&account, c, Summary{}, 60, now)
if err != nil {
t.Fatal(err)
}
if state.Level != 2 || state.Experience != 20 {
t.Fatalf("level-up level=%d exp=%d, want 2/20", state.Level, state.Experience)
}
// Idempotent read: the ledger is durable, so a fresh read agrees.
read, err := readGrowthState(account, c, Summary{})
if err != nil {
t.Fatal(err)
}
if read != state {
t.Fatalf("persisted=%+v in-memory=%+v", read, state)
}
// At the cap the bar and the fraction are both cleared.
state, err = ApplyGrowthExperience(&account, c, Summary{}, 1000, now)
if err != nil {
t.Fatal(err)
}
if state.Level != 3 || state.Experience != 0 || state.Remainder != 0 {
t.Fatalf("capped level=%d exp=%d rem=%d", state.Level, state.Experience, state.Remainder)
}
}
// A sub-unit rate must carry the fraction so many small grants add up to the
// same total as one large one.
func TestApplyGrowthExperienceCarriesSubUnitRateRemainder(t *testing.T) {
c := growthTestConfig()
c.RateParts = growthRateScale / 2 // every grant counts half
account := dnfrepo.AccountRecord{AccountID: "a", Metadata: map[string]string{}}
now := time.Unix(1_800_000_000, 0)
if _, err := ApplyGrowthExperience(&account, c, Summary{}, 100, now); err != nil {
t.Fatal(err)
}
state, err := ApplyGrowthExperience(&account, c, Summary{}, 100, now)
if err != nil {
t.Fatal(err)
}
// 100 halved twice = 100 total, i.e. exactly level 2.
if state.Level != 2 || state.Experience != 0 {
t.Fatalf("level=%d exp=%d, want 2/0 (the half-unit must not be dropped)", state.Level, state.Experience)
}
}
func TestSeedGrowthStateMigratesLegacyLevelOnce(t *testing.T) {
c := growthTestConfig()
account := dnfrepo.AccountRecord{AccountID: "a", Metadata: map[string]string{}}
now := time.Unix(1_800_000_000, 0)
seeded, err := SeedGrowthState(&account, c, Summary{ManageLevel: 2, TotalPoint: 999}, now)
if err != nil || !seeded {
t.Fatalf("seed=%t err=%v", seeded, err)
}
state, err := readGrowthState(account, c, Summary{})
if err != nil {
t.Fatal(err)
}
// The legacy level is carried over, but its point scale is the roster's and
// must not be reused as growth progress.
if state.Level != 2 || state.Experience != 0 {
t.Fatalf("migrated level=%d exp=%d, want 2/0", state.Level, state.Experience)
}
again, err := SeedGrowthState(&account, c, Summary{ManageLevel: 1}, now)
if err != nil || again {
t.Fatalf("second seed=%t err=%v, want no-op", again, err)
}
}
@@ -0,0 +1,224 @@
package adventuregroup
import (
"context"
"fmt"
"math"
"strings"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
)
const (
pointBonusSection = "point bonus"
managePointSection = "manage level point"
manageLevelMaxSection = "manage level max"
expBonusSection = "exp bonus"
goldBonusSection = "gold bonus"
manageOptionSection = "manage option"
)
// Load parses the typed character-management document. It remains the narrow
// calculator loader used by domain tests and tooling.
func Load(ctx context.Context, source dnfpvf.Source) (*Tables, error) {
if source == nil {
return nil, dnfpvf.ErrSourceRequired
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return nil, err
}
text, err := source.ReadText(CharacterManagementPath)
if err != nil {
return nil, fmt.Errorf("read dnf adventure-group PVF %q: %w", CharacterManagementPath, err)
}
if err := ctx.Err(); err != nil {
return nil, err
}
doc, err := dnfpvf.Parse(CharacterManagementPath, text)
if err != nil {
return nil, fmt.Errorf("parse dnf adventure-group PVF %q: %w", CharacterManagementPath, err)
}
tables, err := parseTables(doc)
if err != nil {
return nil, err
}
return tables, nil
}
// LoadComplete loads the management table together with the current 2018
// expedition, shop and growth-capsule documents. Production composition uses
// this strict compatibility-unit loader.
func LoadComplete(ctx context.Context, source dnfpvf.Source) (*Tables, error) {
tables, err := Load(ctx, source)
if err != nil {
return nil, err
}
runtime, err := loadRuntimeConfig(ctx, source)
if err != nil {
return nil, err
}
tables.runtime = runtime
return tables, nil
}
func parseTables(doc *dnfpvf.Document) (*Tables, error) {
pointValues, err := numericSection(doc, pointBonusSection, false)
if err != nil {
return nil, err
}
pointRanges, err := parsePointRanges(pointValues)
if err != nil {
return nil, err
}
thresholdValues, err := numericSection(doc, managePointSection, false)
if err != nil {
return nil, err
}
thresholds, err := parseThresholds(thresholdValues)
if err != nil {
return nil, err
}
maxValues, err := numericSection(doc, manageLevelMaxSection, false)
if err != nil {
return nil, err
}
if len(maxValues) != 1 || maxValues[0] <= 0 || maxValues[0] > int64(math.MaxInt) {
return nil, fmt.Errorf("%w: [%s] values=%v", ErrTableMalformed, manageLevelMaxSection, maxValues)
}
manageLevelMax := int(maxValues[0])
expValues, err := numericSection(doc, expBonusSection, true)
if err != nil {
return nil, err
}
expBonus, err := parseLevelValueTable(expBonusSection, expValues, manageLevelMax, true)
if err != nil {
return nil, err
}
goldValues, err := numericSection(doc, goldBonusSection, true)
if err != nil {
return nil, err
}
goldBonus, err := parseLevelValueTable(goldBonusSection, goldValues, manageLevelMax, true)
if err != nil {
return nil, err
}
optionValues, err := numericSection(doc, manageOptionSection, false)
if err != nil {
return nil, err
}
options, err := parseLevelValueTable(manageOptionSection, optionValues, manageLevelMax, false)
if err != nil {
return nil, err
}
return &Tables{
pointRanges: pointRanges,
manageThresholds: thresholds,
manageLevelMax: manageLevelMax,
expBonusByLevel: expBonus,
goldBonusByLevel: goldBonus,
optionByLevel: options,
}, nil
}
func numericSection(doc *dnfpvf.Document, name string, allowEmpty bool) ([]int64, error) {
if doc == nil {
return nil, fmt.Errorf("%w: %s [%s]", ErrTableEmpty, CharacterManagementPath, name)
}
count := 0
for _, section := range doc.Sections {
if strings.EqualFold(strings.TrimSpace(section.Name), name) {
count++
}
}
if count == 0 {
return nil, fmt.Errorf("%w: %s missing [%s]", ErrTableEmpty, CharacterManagementPath, name)
}
if count != 1 {
return nil, fmt.Errorf("%w: %s duplicate [%s] count=%d", ErrTableMalformed, CharacterManagementPath, name, count)
}
tokens, ok := doc.Section(name)
if !ok {
return nil, fmt.Errorf("%w: %s unreadable [%s]", ErrTableMalformed, CharacterManagementPath, name)
}
values := make([]int64, 0, len(tokens))
for _, token := range tokens {
if token.Kind != dnfpvf.TokenInt {
return nil, fmt.Errorf("%w: %s [%s] non-integer token %q at %d:%d", ErrTableMalformed, CharacterManagementPath, name, token.Raw, token.Line, token.Column)
}
values = append(values, token.Int)
}
if len(values) == 0 && !allowEmpty {
return nil, fmt.Errorf("%w: %s [%s]", ErrTableEmpty, CharacterManagementPath, name)
}
return values, nil
}
func parsePointRanges(values []int64) ([]pointRange, error) {
if len(values)%3 != 0 {
return nil, fmt.Errorf("%w: [%s] integers=%d", ErrTableMalformed, pointBonusSection, len(values))
}
rows := make([]pointRange, 0, len(values)/3)
previousMax := 0
for index := 0; index < len(values); index += 3 {
minValue, maxValue, pointValue := values[index], values[index+1], values[index+2]
if minValue <= 0 || minValue > int64(math.MaxInt) || maxValue < minValue || maxValue > int64(math.MaxInt) || pointValue < 0 {
return nil, fmt.Errorf("%w: [%s] row=%d min=%d max=%d point=%d", ErrTableMalformed, pointBonusSection, index/3, minValue, maxValue, pointValue)
}
minLevel, maxLevel := int(minValue), int(maxValue)
if minLevel <= previousMax {
return nil, fmt.Errorf("%w: [%s] row=%d range=%d..%d overlaps or is unsorted after max=%d", ErrTableMalformed, pointBonusSection, index/3, minLevel, maxLevel, previousMax)
}
rows = append(rows, pointRange{minLevel: minLevel, maxLevel: maxLevel, point: uint64(pointValue)})
previousMax = maxLevel
}
return rows, nil
}
func parseThresholds(values []int64) ([]uint64, error) {
thresholds := make([]uint64, len(values))
var previous uint64
for index, value := range values {
if value <= 0 {
return nil, fmt.Errorf("%w: [%s] index=%d value=%d", ErrTableMalformed, managePointSection, index, value)
}
threshold := uint64(value)
if index > 0 && threshold <= previous {
return nil, fmt.Errorf("%w: [%s] index=%d value=%d previous=%d", ErrTableMalformed, managePointSection, index, threshold, previous)
}
thresholds[index] = threshold
previous = threshold
}
return thresholds, nil
}
func parseLevelValueTable(name string, values []int64, manageLevelMax int, allowEmpty bool) (map[int]uint64, error) {
if len(values) == 0 {
if allowEmpty {
return map[int]uint64{}, nil
}
return nil, fmt.Errorf("%w: [%s]", ErrTableEmpty, name)
}
if len(values)%2 != 0 {
return nil, fmt.Errorf("%w: [%s] integers=%d", ErrTableMalformed, name, len(values))
}
table := make(map[int]uint64, len(values)/2)
previousLevel := 0
for index := 0; index < len(values); index += 2 {
levelValue, dataValue := values[index], values[index+1]
if levelValue <= 0 || levelValue > int64(manageLevelMax) || levelValue > int64(math.MaxInt) || dataValue < 0 {
return nil, fmt.Errorf("%w: [%s] row=%d level=%d value=%d", ErrTableMalformed, name, index/2, levelValue, dataValue)
}
level := int(levelValue)
if level <= previousLevel {
return nil, fmt.Errorf("%w: [%s] row=%d level=%d previous=%d", ErrTableMalformed, name, index/2, level, previousLevel)
}
table[level] = uint64(dataValue)
previousLevel = level
}
return table, nil
}
@@ -0,0 +1,124 @@
package adventuregroup
import (
"context"
"errors"
"fmt"
"math"
"strconv"
"strings"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
var ErrDailyLoginStateInvalid = errors.New("adventure-group daily-login state is invalid")
var adventureGroupCalendarLocation = time.FixedZone("Asia/Shanghai", 8*60*60)
type ObserveDailyLoginCommand struct {
AccountID string
ObservedAt time.Time
}
type ObserveDailyLoginResult struct {
AccountID string
CalendarDate string
ConsecutiveDays uint32
Changed bool
}
// ObserveDailyLogin advances one account's UTC+8 calendar-day streak. The date
// and count share one metadata value so the key-level write cannot persist a
// partially updated streak.
func (o *Owner) ObserveDailyLogin(
ctx context.Context,
command ObserveDailyLoginCommand,
) (ObserveDailyLoginResult, error) {
if o == nil || o.accounts == nil {
return ObserveDailyLoginResult{}, ErrOwnerUnavailable
}
accountID := strings.TrimSpace(command.AccountID)
if accountID == "" {
return ObserveDailyLoginResult{}, ErrAccountRequired
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return ObserveDailyLoginResult{}, err
}
observedAt := command.ObservedAt
if observedAt.IsZero() {
observedAt = time.Now()
}
today := calendarDate(observedAt)
account, found, err := o.accounts.Load(ctx, accountID)
if err != nil {
return ObserveDailyLoginResult{}, fmt.Errorf("load adventure-group account: %w", err)
}
if !found || strings.TrimSpace(account.AccountID) != accountID {
return ObserveDailyLoginResult{}, fmt.Errorf("%w: account=%s", ErrAccountNotFound, accountID)
}
previousDate, previousCount, hasState, err := parseDailyLoginState(account.Metadata[DailyLoginStateMetadataKey])
if err != nil {
return ObserveDailyLoginResult{}, err
}
result := ObserveDailyLoginResult{
AccountID: accountID,
CalendarDate: today.Format(time.DateOnly),
ConsecutiveDays: previousCount,
}
if hasState && !today.After(previousDate) {
return result, nil
}
switch {
case !hasState:
result.ConsecutiveDays = 1
case today.Equal(previousDate.AddDate(0, 0, 1)):
if previousCount < math.MaxUint32 {
result.ConsecutiveDays = previousCount + 1
}
default:
result.ConsecutiveDays = 1
}
value := result.CalendarDate + "|" + strconv.FormatUint(uint64(result.ConsecutiveDays), 10)
if err := dnfrepo.SaveAccountMetadataEntry(
ctx,
o.accounts,
account,
DailyLoginStateMetadataKey,
value,
observedAt,
); err != nil {
return ObserveDailyLoginResult{}, fmt.Errorf("save adventure-group daily login: %w", err)
}
result.Changed = true
return result, nil
}
func calendarDate(value time.Time) time.Time {
local := value.In(adventureGroupCalendarLocation)
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, adventureGroupCalendarLocation)
}
func parseDailyLoginState(value string) (time.Time, uint32, bool, error) {
value = strings.TrimSpace(value)
if value == "" {
return time.Time{}, 0, false, nil
}
dateText, countText, ok := strings.Cut(value, "|")
if !ok {
return time.Time{}, 0, false, fmt.Errorf("%w: %q", ErrDailyLoginStateInvalid, value)
}
date, err := time.ParseInLocation(time.DateOnly, dateText, adventureGroupCalendarLocation)
if err != nil {
return time.Time{}, 0, false, fmt.Errorf("%w: date=%q", ErrDailyLoginStateInvalid, dateText)
}
count, err := strconv.ParseUint(countText, 10, 32)
if err != nil || count == 0 {
return time.Time{}, 0, false, fmt.Errorf("%w: count=%q", ErrDailyLoginStateInvalid, countText)
}
return date, uint32(count), true, nil
}
@@ -0,0 +1,110 @@
package adventuregroup
import (
"context"
"errors"
"math"
"testing"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestOwnerObserveDailyLoginStartsAdvancesAndResetsStreak(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "account-1",
Metadata: map[string]string{"account_cera": "123"},
}); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
location := time.FixedZone("test", 8*60*60)
observe := func(at time.Time) ObserveDailyLoginResult {
t.Helper()
result, observeErr := owner.ObserveDailyLogin(ctx, ObserveDailyLoginCommand{
AccountID: "account-1",
ObservedAt: at,
})
if observeErr != nil {
t.Fatal(observeErr)
}
return result
}
first := observe(time.Date(2026, 7, 28, 8, 0, 0, 0, location))
if !first.Changed || first.ConsecutiveDays != 1 || first.CalendarDate != "2026-07-28" {
t.Fatalf("first=%+v", first)
}
same := observe(time.Date(2026, 7, 28, 23, 59, 0, 0, location))
if same.Changed || same.ConsecutiveDays != 1 {
t.Fatalf("same=%+v", same)
}
next := observe(time.Date(2026, 7, 29, 0, 1, 0, 0, location))
if !next.Changed || next.ConsecutiveDays != 2 {
t.Fatalf("next=%+v", next)
}
gap := observe(time.Date(2026, 7, 31, 0, 1, 0, 0, location))
if !gap.Changed || gap.ConsecutiveDays != 1 {
t.Fatalf("gap=%+v", gap)
}
account, found, err := repositories.Account.Load(ctx, "account-1")
if err != nil || !found {
t.Fatalf("load found=%t err=%v", found, err)
}
if account.Metadata[DailyLoginStateMetadataKey] != "2026-07-31|1" ||
account.Metadata["account_cera"] != "123" {
t.Fatalf("account metadata=%v", account.Metadata)
}
}
func TestOwnerObserveDailyLoginPreservesFutureAndSaturates(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "account-1",
Metadata: map[string]string{
DailyLoginStateMetadataKey: "2026-07-29|4294967295",
},
}); err != nil {
t.Fatal(err)
}
owner, _ := NewOwner(repositories)
result, err := owner.ObserveDailyLogin(ctx, ObserveDailyLoginCommand{
AccountID: "account-1",
ObservedAt: time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC),
})
if err != nil || result.Changed || result.ConsecutiveDays != math.MaxUint32 {
t.Fatalf("future result=%+v err=%v", result, err)
}
result, err = owner.ObserveDailyLogin(ctx, ObserveDailyLoginCommand{
AccountID: "account-1",
ObservedAt: time.Date(2026, 7, 30, 12, 0, 0, 0, time.UTC),
})
if err != nil || !result.Changed || result.ConsecutiveDays != math.MaxUint32 {
t.Fatalf("saturated result=%+v err=%v", result, err)
}
}
func TestOwnerObserveDailyLoginRejectsCorruptState(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "account-1",
Metadata: map[string]string{DailyLoginStateMetadataKey: "bad"},
}); err != nil {
t.Fatal(err)
}
owner, _ := NewOwner(repositories)
if _, err := owner.ObserveDailyLogin(ctx, ObserveDailyLoginCommand{
AccountID: "account-1",
ObservedAt: time.Now(),
}); !errors.Is(err, ErrDailyLoginStateInvalid) {
t.Fatalf("error=%v", err)
}
}
@@ -0,0 +1,99 @@
package adventuregroup
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const SelectorSlotMetadataKey = "selector_adventure_info_slot"
var (
ErrOwnerUnavailable = errors.New("adventure-group owner is unavailable")
ErrAccountRequired = errors.New("adventure-group account id is required")
ErrAccountNotFound = errors.New("adventure-group account is missing")
ErrSelectorSlotRange = errors.New("adventure-group selector slot is outside the verified range")
)
type RememberSelectorSlotCommand struct {
AccountID string
Slot int
SlotLimit int
UpdatedAt time.Time
}
type RememberSelectorSlotResult struct {
AccountID string
Slot int
Changed bool
}
type Owner struct {
accounts dnfrepo.AccountRepository
}
func NewOwner(repositories dnfrepo.Group) (*Owner, error) {
if repositories.Account == nil {
return nil, ErrOwnerUnavailable
}
return &Owner{accounts: repositories.Account}, nil
}
// RememberSelectorSlot persists only a slot already verified against the
// account roster by the bridge. The metadata write is key-scoped so unrelated
// account fields and metadata cannot be replaced.
func (o *Owner) RememberSelectorSlot(
ctx context.Context,
command RememberSelectorSlotCommand,
) (RememberSelectorSlotResult, error) {
if o == nil || o.accounts == nil {
return RememberSelectorSlotResult{}, ErrOwnerUnavailable
}
accountID := strings.TrimSpace(command.AccountID)
if accountID == "" {
return RememberSelectorSlotResult{}, ErrAccountRequired
}
if command.SlotLimit <= 0 || command.Slot < 0 || command.Slot >= command.SlotLimit {
return RememberSelectorSlotResult{}, fmt.Errorf(
"%w: slot=%d limit=%d",
ErrSelectorSlotRange,
command.Slot,
command.SlotLimit,
)
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return RememberSelectorSlotResult{}, err
}
account, found, err := o.accounts.Load(ctx, accountID)
if err != nil {
return RememberSelectorSlotResult{}, fmt.Errorf("load adventure-group account: %w", err)
}
if !found || strings.TrimSpace(account.AccountID) != accountID {
return RememberSelectorSlotResult{}, fmt.Errorf("%w: account=%s", ErrAccountNotFound, accountID)
}
result := RememberSelectorSlotResult{AccountID: accountID, Slot: command.Slot}
value := strconv.Itoa(command.Slot)
if strings.TrimSpace(account.Metadata[SelectorSlotMetadataKey]) == value {
return result, nil
}
if err := dnfrepo.SaveAccountMetadataEntry(
ctx,
o.accounts,
account,
SelectorSlotMetadataKey,
value,
command.UpdatedAt,
); err != nil {
return RememberSelectorSlotResult{}, fmt.Errorf("save adventure-group selector slot: %w", err)
}
result.Changed = true
return result, nil
}
@@ -0,0 +1,101 @@
package adventuregroup
import (
"context"
"errors"
"testing"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestOwnerRememberSelectorSlotUpdatesOnlyOwnedMetadataKey(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
now := time.Unix(2_000_000_000, 0).UTC()
if err := repositories.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "account-1",
State: "active",
HonorExp: 77,
RepresentAccountName: "group",
Metadata: map[string]string{
"account_cera": "123",
},
}); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
result, err := owner.RememberSelectorSlot(ctx, RememberSelectorSlotCommand{
AccountID: "account-1",
Slot: 4,
SlotLimit: 32,
UpdatedAt: now,
})
if err != nil {
t.Fatal(err)
}
if !result.Changed || result.Slot != 4 {
t.Fatalf("result=%+v", result)
}
account, found, err := repositories.Account.Load(ctx, "account-1")
if err != nil || !found {
t.Fatalf("load found=%t err=%v", found, err)
}
if account.Metadata[SelectorSlotMetadataKey] != "4" ||
account.Metadata["account_cera"] != "123" ||
account.HonorExp != 77 ||
account.RepresentAccountName != "group" ||
!account.UpdatedAt.Equal(now) {
t.Fatalf("account=%+v", account)
}
}
func TestOwnerRememberSelectorSlotPreservesExistingValue(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "account-1",
Metadata: map[string]string{SelectorSlotMetadataKey: "4"},
}); err != nil {
t.Fatal(err)
}
owner, _ := NewOwner(repositories)
result, err := owner.RememberSelectorSlot(ctx, RememberSelectorSlotCommand{
AccountID: "account-1",
Slot: 4,
SlotLimit: 32,
})
if err != nil {
t.Fatal(err)
}
if result.Changed {
t.Fatalf("result=%+v", result)
}
}
func TestOwnerRememberSelectorSlotRejectsInvalidScope(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Account.Save(ctx, dnfrepo.AccountRecord{AccountID: "account-1"}); err != nil {
t.Fatal(err)
}
owner, _ := NewOwner(repositories)
if _, err := owner.RememberSelectorSlot(ctx, RememberSelectorSlotCommand{
AccountID: "account-1",
Slot: 32,
SlotLimit: 32,
}); !errors.Is(err, ErrSelectorSlotRange) {
t.Fatalf("slot error=%v", err)
}
if _, err := owner.RememberSelectorSlot(ctx, RememberSelectorSlotCommand{
AccountID: "missing",
Slot: 1,
SlotLimit: 32,
}); !errors.Is(err, ErrAccountNotFound) {
t.Fatalf("account error=%v", err)
}
}
@@ -0,0 +1,246 @@
package adventuregroup
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const (
representativeSettingsScopePrefix = "account:"
representativeSettingsScopeSuffix = ":adventure_representatives"
maximumRepresentativeJob = byte(15)
minimumRepresentativeAwakening = byte(2)
minimumRepresentativeBaseGrow = byte(1)
maximumRepresentativeBaseGrow = byte(15)
representativeRemovedValue = "removed"
representativeSelectedPrefix = "selected:"
)
var (
ErrRepresentativeRepository = errors.New("adventure-group representative repository is unavailable")
ErrRepresentativeSlot = errors.New("adventure-group representative slot is invalid")
ErrRepresentativeCharacter = errors.New("adventure-group representative character is invalid")
)
type RepresentativeSlot struct {
Job byte
BaseGrow byte
}
type RepresentativeSelection struct {
CharacterID string
Removed bool
}
type RepresentativeState struct {
Selections map[RepresentativeSlot]RepresentativeSelection
}
func ValidRepresentativeSlot(slot RepresentativeSlot) bool {
return slot.Job <= maximumRepresentativeJob &&
slot.BaseGrow >= minimumRepresentativeBaseGrow &&
slot.BaseGrow <= maximumRepresentativeBaseGrow
}
func RepresentativeCharacterEligible(character dnfrepo.CharacterRecord, slot RepresentativeSlot, maximumLevel int) bool {
if !ValidRepresentativeSlot(slot) || maximumLevel <= 0 || character.Level < maximumLevel ||
byte(numericRecordValue(character.Job)) != slot.Job {
return false
}
packed := byte(character.Stats["grow_type"])
return packed&0x0f == slot.BaseGrow && packed>>4 >= minimumRepresentativeAwakening
}
func RepresentativeSlotForCharacter(character dnfrepo.CharacterRecord, maximumLevel int) (RepresentativeSlot, bool) {
packed := byte(character.Stats["grow_type"])
slot := RepresentativeSlot{Job: byte(numericRecordValue(character.Job)), BaseGrow: packed & 0x0f}
return slot, RepresentativeCharacterEligible(character, slot, maximumLevel)
}
func ProjectRepresentativeCharacters(
characters []dnfrepo.CharacterRecord,
state RepresentativeState,
maximumLevel int,
) []dnfrepo.CharacterRecord {
fallbacks := make(map[RepresentativeSlot]dnfrepo.CharacterRecord)
eligibleByID := make(map[string]dnfrepo.CharacterRecord)
order := make([]RepresentativeSlot, 0)
for _, character := range characters {
slot, eligible := RepresentativeSlotForCharacter(character, maximumLevel)
if !eligible {
continue
}
characterID := strings.TrimSpace(character.CharacterID)
if characterID != "" {
eligibleByID[characterID] = character
}
if _, exists := fallbacks[slot]; !exists {
fallbacks[slot] = character
order = append(order, slot)
}
}
result := make([]dnfrepo.CharacterRecord, 0, len(order))
for _, slot := range order {
selection, selected := state.Selections[slot]
if selected && selection.Removed {
continue
}
if selected && strings.TrimSpace(selection.CharacterID) != "" {
if character, found := eligibleByID[strings.TrimSpace(selection.CharacterID)]; found {
characterSlot, eligible := RepresentativeSlotForCharacter(character, maximumLevel)
if eligible && characterSlot == slot {
result = append(result, character)
continue
}
}
}
result = append(result, fallbacks[slot])
}
return result
}
type RepresentativeOwner struct {
settings dnfrepo.SettingsRepository
}
func NewRepresentativeOwner(repositories dnfrepo.Group) (*RepresentativeOwner, error) {
if repositories.Settings == nil {
return nil, ErrRepresentativeRepository
}
return &RepresentativeOwner{settings: repositories.Settings}, nil
}
func RepresentativeSettingsScope(accountID string) string {
return representativeSettingsScopePrefix + strings.TrimSpace(accountID) + representativeSettingsScopeSuffix
}
func (o *RepresentativeOwner) Load(ctx context.Context, accountID string) (RepresentativeState, error) {
if o == nil || o.settings == nil {
return RepresentativeState{}, ErrRepresentativeRepository
}
accountID = strings.TrimSpace(accountID)
if accountID == "" {
return RepresentativeState{}, ErrAccountRequired
}
if ctx == nil {
ctx = context.Background()
}
record, found, err := o.settings.Load(ctx, RepresentativeSettingsScope(accountID))
if err != nil {
return RepresentativeState{}, fmt.Errorf("load adventure-group representatives: %w", err)
}
state := RepresentativeState{Selections: make(map[RepresentativeSlot]RepresentativeSelection)}
if !found {
return state, nil
}
for key, value := range record.Values {
slot, valid := parseRepresentativeSlotKey(key)
if !valid {
continue
}
switch {
case strings.TrimSpace(value) == representativeRemovedValue:
state.Selections[slot] = RepresentativeSelection{Removed: true}
case strings.HasPrefix(strings.TrimSpace(value), representativeSelectedPrefix):
characterID := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(value), representativeSelectedPrefix))
if characterID != "" {
state.Selections[slot] = RepresentativeSelection{CharacterID: characterID}
}
}
}
return state, nil
}
func (o *RepresentativeOwner) Select(
ctx context.Context,
accountID string,
slot RepresentativeSlot,
characterID string,
updatedAt time.Time,
) error {
characterID = strings.TrimSpace(characterID)
if !ValidRepresentativeSlot(slot) {
return ErrRepresentativeSlot
}
if characterID == "" {
return ErrRepresentativeCharacter
}
return o.save(ctx, accountID, slot, representativeSelectedPrefix+characterID, updatedAt)
}
func (o *RepresentativeOwner) Remove(
ctx context.Context,
accountID string,
slot RepresentativeSlot,
updatedAt time.Time,
) error {
if !ValidRepresentativeSlot(slot) {
return ErrRepresentativeSlot
}
return o.save(ctx, accountID, slot, representativeRemovedValue, updatedAt)
}
func (o *RepresentativeOwner) save(
ctx context.Context,
accountID string,
slot RepresentativeSlot,
value string,
updatedAt time.Time,
) error {
if o == nil || o.settings == nil {
return ErrRepresentativeRepository
}
accountID = strings.TrimSpace(accountID)
if accountID == "" {
return ErrAccountRequired
}
if ctx == nil {
ctx = context.Background()
}
scope := RepresentativeSettingsScope(accountID)
record, found, err := o.settings.Load(ctx, scope)
if err != nil {
return fmt.Errorf("load adventure-group representatives for update: %w", err)
}
if !found {
record = dnfrepo.SettingsRecord{Scope: scope, Values: make(map[string]string)}
} else {
record = dnfrepo.CloneSettings(record)
if record.Values == nil {
record.Values = make(map[string]string)
}
}
record.Values[representativeSlotKey(slot)] = value
record.UpdatedAt = updatedAt
if err := o.settings.Save(ctx, record); err != nil {
return fmt.Errorf("save adventure-group representatives: %w", err)
}
return nil
}
func representativeSlotKey(slot RepresentativeSlot) string {
return strconv.Itoa(int(slot.Job)) + ":" + strconv.Itoa(int(slot.BaseGrow))
}
func parseRepresentativeSlotKey(value string) (RepresentativeSlot, bool) {
parts := strings.Split(strings.TrimSpace(value), ":")
if len(parts) != 2 {
return RepresentativeSlot{}, false
}
job, jobErr := strconv.ParseUint(parts[0], 10, 8)
grow, growErr := strconv.ParseUint(parts[1], 10, 8)
slot := RepresentativeSlot{Job: byte(job), BaseGrow: byte(grow)}
return slot, jobErr == nil && growErr == nil && ValidRepresentativeSlot(slot)
}
func numericRecordValue(value string) int64 {
parsed, _ := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
return parsed
}
@@ -0,0 +1,95 @@
package adventuregroup
import (
"context"
"testing"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestRepresentativeOwnerRoundTripAndAccountIsolation(t *testing.T) {
repositories := dnfrepomemory.NewMemoryGroup()
owner, err := NewRepresentativeOwner(repositories)
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
slot := RepresentativeSlot{Job: 11, BaseGrow: 4}
if err := owner.Select(ctx, "dnf:1", slot, "267", time.Unix(1, 0)); err != nil {
t.Fatal(err)
}
state, err := owner.Load(ctx, "dnf:1")
if err != nil {
t.Fatal(err)
}
if got := state.Selections[slot]; got != (RepresentativeSelection{CharacterID: "267"}) {
t.Fatalf("selection=%+v", got)
}
other, err := owner.Load(ctx, "dnf:2")
if err != nil {
t.Fatal(err)
}
if len(other.Selections) != 0 {
t.Fatalf("cross-account selections=%+v", other.Selections)
}
if err := owner.Remove(ctx, "dnf:1", slot, time.Unix(2, 0)); err != nil {
t.Fatal(err)
}
state, err = owner.Load(ctx, "dnf:1")
if err != nil {
t.Fatal(err)
}
if got := state.Selections[slot]; !got.Removed || got.CharacterID != "" {
t.Fatalf("removed selection=%+v", got)
}
}
func TestProjectRepresentativeCharactersUsesSelectionRemovalAndStaleFallback(t *testing.T) {
characters := []dnfrepo.CharacterRecord{
{CharacterID: "1", Job: "0", Level: 90, Stats: map[string]int64{"grow_type": 0x21}},
{CharacterID: "2", Job: "0", Level: 90, Stats: map[string]int64{"grow_type": 0x31}},
{CharacterID: "3", Job: "1", Level: 90, Stats: map[string]int64{"grow_type": 0x22}},
{CharacterID: "4", Job: "1", Level: 89, Stats: map[string]int64{"grow_type": 0x22}},
{CharacterID: "5", Job: "2", Level: 90, Stats: map[string]int64{"grow_type": 0x12}},
}
slotA := RepresentativeSlot{Job: 0, BaseGrow: 1}
slotB := RepresentativeSlot{Job: 1, BaseGrow: 2}
projected := ProjectRepresentativeCharacters(characters, RepresentativeState{
Selections: map[RepresentativeSlot]RepresentativeSelection{
slotA: {CharacterID: "2"},
slotB: {Removed: true},
},
}, 90)
if len(projected) != 1 || projected[0].CharacterID != "2" {
t.Fatalf("selected/removal projection=%+v", projected)
}
projected = ProjectRepresentativeCharacters(characters, RepresentativeState{
Selections: map[RepresentativeSlot]RepresentativeSelection{
slotA: {CharacterID: "999"},
},
}, 90)
if len(projected) != 2 || projected[0].CharacterID != "1" || projected[1].CharacterID != "3" {
t.Fatalf("stale fallback projection=%+v", projected)
}
}
func TestRepresentativeCharacterEligibilityUsesPackedGrow(t *testing.T) {
slot := RepresentativeSlot{Job: 11, BaseGrow: 4}
character := dnfrepo.CharacterRecord{
Job: "11", Level: 90, Stats: map[string]int64{"grow_type": 0x24},
}
if !RepresentativeCharacterEligible(character, slot, 90) {
t.Fatal("second-awakening packed grow was rejected")
}
character.Stats["grow_type"] = 0x14
if RepresentativeCharacterEligible(character, slot, 90) {
t.Fatal("first-awakening packed grow was accepted")
}
character.Stats["grow_type"] = 0x24
character.Level = 89
if RepresentativeCharacterEligible(character, slot, 90) {
t.Fatal("below-cap character was accepted")
}
}
@@ -0,0 +1,756 @@
package adventuregroup
import (
"context"
"fmt"
"math"
"sort"
"strings"
"time"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
)
const (
ShopPointBrave = iota
ShopPointGlory
ShopPointPure
ShopPointTypeCount
)
type ShopProduct struct {
ItemID uint32
RequiredManageLevel int
Cost uint32
MonthlyPurchaseLimit uint32
ResetType int
}
type ShopCategory struct {
Index byte
MaxPoint uint32
ResetPointMonthly bool
ExperiencePerPoint uint64
PointPerExperience uint32
Products []ShopProduct
}
type CapsuleConfig struct {
MinimumExperience uint64
MaximumExperience uint64
MaximumCount uint32
MinimumLevel int
MaximumLevel int
GrantedExperience uint32
UpdateAt time.Time
}
// CapsuleTotalCapacity 返回成长胶囊槽的**总容量**。
//
// PVF 里的两个数是两件事,绝不能混:
//
// [capsule max exp] 116,914,950 = **一格**的容量(界面上的 100%)
// [capsule max count] 10 = 可以攒几格(界面写"累积数上限 : 10个")
//
// 所以总容量 = 116,914,950 × 10。把 `[capsule max exp]` 当成总上限会让累计值
// 卡死在正好 100% 上:客户端那条进度条的分子是 `累计值 mod [capsule max exp]`,
// 于是永远显示 0%、玩家看到的就是"胶囊不累计"(2026-09-23 线上现场)。
func CapsuleTotalCapacity(config CapsuleConfig) uint64 {
if config.MaximumExperience == 0 {
return 0
}
count := config.MaximumCount
if count == 0 {
count = 1
}
if config.MaximumExperience > math.MaxUint64/uint64(count) {
return math.MaxUint64
}
return config.MaximumExperience * uint64(count)
}
// CapsuleGaugeCapacity 返回一格(100%)的容量,也就是客户端进度条的分母。
func CapsuleGaugeCapacity(config CapsuleConfig) uint64 {
return config.MaximumExperience
}
// CapsuleClaimCount 返回"现在可以领取几次成长胶囊经验值"。
//
// 客户端自己的说明文字(Interface2/Adventurer2018/AdvShop 的
// `capsule_explain`)把规则写死了:
//
// 经验值按照10% : 1,000,000点的比例换算。
// (累积100%时, 可获得10,000,000点经验值)
// 角色Lv : Lv.50 ~ 86
// 累积数上限 : 10个
//
// 所以一次领取 = 消耗 `[capsule need minimum exp]`(= 一格 10%,
// 116,914,950 / 10 = 11,691,495)= 换到 `[capsule give exp]`(1,000,000),
// 可领取次数 = floor(累计经验 / 该档位),一格 = 10 次。
//
// 上限必须是"格数 × 每格档数"(10 × 10 = 100):客户端拿到这个数会**按它
// 建列表**(0x13F0FA0 里 `for (i = 0; i < count; i++)` 逐个拼字符串),
// 所以绝不能把"可领取的经验值总量"(1,000 万级)当成次数发出去 —— 那会让
// 客户端建上千万个字符串而卡死。2026-09-23 第一版就写错了,装机前发现并回滚。
func CapsuleClaimCount(config CapsuleConfig, accumulated uint64) uint32 {
if config.MinimumExperience == 0 || config.MaximumExperience < config.MinimumExperience {
return 0
}
chunks := accumulated / config.MinimumExperience
maximum := uint64(config.MaximumCount)
if maximum == 0 {
maximum = 1
}
if perGauge := config.MaximumExperience / config.MinimumExperience; perGauge > 0 {
if maximum > math.MaxUint32/perGauge {
maximum = math.MaxUint32
} else {
maximum *= perGauge
}
}
if chunks > maximum {
chunks = maximum
}
if chunks > math.MaxUint32 {
return math.MaxUint32
}
return uint32(chunks)
}
type ExpeditionArea struct {
Index byte
RequiredManageLevel int
RewardRates map[uint32]float64
DesignatedGroups []string
}
type RuntimeConfig struct {
ShopCategories []ShopCategory
Growth GrowthConfig
Capsule CapsuleConfig
ExpeditionAreas []ExpeditionArea
AttributeIDs map[string]byte
AttributeGroups map[string][]string
CharacterAttributes map[[2]byte][]string
AttributeRewardRates []float64
// PartyMemberRewardRates is `[party member rate]` (5 entries, indexed by
// member count) and ExpeditionExtraRate is `[extra rate]`. Both feed the
// native expedition reward formula.
PartyMemberRewardRates []float64
ExpeditionExtraRate float64
RotationDays int
professionClasses map[[2]byte]byte
professionGroupCount int
}
func (c RuntimeConfig) Clone() RuntimeConfig {
out := c
out.Growth.RequiredExperience = append([]uint64(nil), c.Growth.RequiredExperience...)
out.Growth.LevelBenefits = append([]Benefits(nil), c.Growth.LevelBenefits...)
out.ShopCategories = make([]ShopCategory, len(c.ShopCategories))
for i, category := range c.ShopCategories {
out.ShopCategories[i] = category
out.ShopCategories[i].Products = append([]ShopProduct(nil), category.Products...)
}
out.ExpeditionAreas = make([]ExpeditionArea, len(c.ExpeditionAreas))
for i, area := range c.ExpeditionAreas {
out.ExpeditionAreas[i] = area
out.ExpeditionAreas[i].RewardRates = make(map[uint32]float64, len(area.RewardRates))
for duration, rate := range area.RewardRates {
out.ExpeditionAreas[i].RewardRates[duration] = rate
}
out.ExpeditionAreas[i].DesignatedGroups = append([]string(nil), area.DesignatedGroups...)
}
out.AttributeIDs = make(map[string]byte, len(c.AttributeIDs))
for key, value := range c.AttributeIDs {
out.AttributeIDs[key] = value
}
out.AttributeGroups = make(map[string][]string, len(c.AttributeGroups))
for key, values := range c.AttributeGroups {
out.AttributeGroups[key] = append([]string(nil), values...)
}
out.CharacterAttributes = make(map[[2]byte][]string, len(c.CharacterAttributes))
for key, values := range c.CharacterAttributes {
out.CharacterAttributes[key] = append([]string(nil), values...)
}
out.AttributeRewardRates = append([]float64(nil), c.AttributeRewardRates...)
out.PartyMemberRewardRates = append([]float64(nil), c.PartyMemberRewardRates...)
out.professionClasses = make(map[[2]byte]byte, len(c.professionClasses))
for key, value := range c.professionClasses {
out.professionClasses[key] = value
}
return out
}
// ProfessionClassification resolves the ordered [expert list] group used by
// the current client collection UI for one job/base-grow slot.
func (c RuntimeConfig) ProfessionClassification(job, baseGrow int) (byte, bool) {
if job < 0 || job > math.MaxUint8 || baseGrow <= 0 || baseGrow > math.MaxUint8 {
return 0, false
}
value, found := c.professionClasses[[2]byte{byte(job), byte(baseGrow)}]
return value, found
}
func (c RuntimeConfig) ProfessionGroupCount() int {
return c.professionGroupCount
}
func (c RuntimeConfig) Shop(index byte) (ShopCategory, bool) {
for _, category := range c.ShopCategories {
if category.Index == index {
copy := category
copy.Products = append([]ShopProduct(nil), category.Products...)
return copy, true
}
}
return ShopCategory{}, false
}
func (c RuntimeConfig) Area(index byte) (ExpeditionArea, bool) {
for _, area := range c.ExpeditionAreas {
if area.Index == index {
copy := area
copy.DesignatedGroups = append([]string(nil), area.DesignatedGroups...)
copy.RewardRates = make(map[uint32]float64, len(area.RewardRates))
for duration, rate := range area.RewardRates {
copy.RewardRates[duration] = rate
}
return copy, true
}
}
return ExpeditionArea{}, false
}
// AreaAttributes resolves the PVF rotation deterministically from the UTC+8
// calendar day. Group order comes from the PVF, and duplicate attributes are
// skipped just like the client's uniqueness presentation.
func (c RuntimeConfig) AreaAttributes(index byte, now time.Time) []byte {
area, ok := c.Area(index)
if !ok {
return nil
}
rotationDays := c.RotationDays
if rotationDays <= 0 {
rotationDays = 1
}
local := now.In(adventureGroupCalendarLocation)
// local.Unix() is absolute UTC seconds, so dividing it by 86400 would roll
// the rotation over at UTC midnight. Add the zone offset to get local
// wall-clock seconds, which puts the boundary at local (UTC+8) midnight.
_, offsetSeconds := local.Zone()
day := int((local.Unix() + int64(offsetSeconds)) / 86400 / int64(rotationDays))
out := make([]byte, 0, len(area.DesignatedGroups))
seen := make(map[byte]struct{}, len(area.DesignatedGroups))
for offset, groupName := range area.DesignatedGroups {
group := c.AttributeGroups[normalizedPVFName(groupName)]
if len(group) == 0 {
if id, exists := c.AttributeIDs[normalizedPVFName(groupName)]; exists {
group = []string{groupName}
_ = id
}
}
if len(group) == 0 {
continue
}
for attempt := 0; attempt < len(group); attempt++ {
name := group[(day+int(index)+offset+attempt)%len(group)]
id, exists := c.AttributeIDs[normalizedPVFName(name)]
if !exists {
continue
}
if _, duplicate := seen[id]; duplicate {
continue
}
seen[id] = struct{}{}
out = append(out, id)
break
}
}
return out
}
// ExpeditionReward follows the native current-EXE formula at 0x38327F0.
//
// Character levels are NOT an input: the reward is the area/duration rate times
// the party-size rate times the attribute-match bonus, rounded to float32 and
// truncated, then scaled by the extra rate. designated is the area's rotated
// attribute set, supplied by the caller so the value the client was shown and
// the value the server pays come from the same snapshot.
func (c RuntimeConfig) ExpeditionReward(areaIndex byte, durationSeconds uint32, members []ExpeditionMemberInput, designated []byte) (uint32, bool) {
area, ok := c.Area(areaIndex)
if !ok || len(members) == 0 || len(members) > 4 ||
len(c.PartyMemberRewardRates) <= len(members) || c.ExpeditionExtraRate <= 0 {
return 0, false
}
rate, ok := area.RewardRates[durationSeconds]
if !ok || rate <= 0 {
return 0, false
}
partyAttributes := make(map[byte]struct{})
for _, member := range members {
if member.Level <= 0 {
return 0, false
}
attributes := c.CharacterAttributes[[2]byte{member.Job, member.GrowType & 0x0f}]
for _, name := range attributes {
if id, exists := c.AttributeIDs[normalizedPVFName(name)]; exists {
partyAttributes[id] = struct{}{}
}
}
}
matched := 0
for _, wanted := range designated {
if _, found := partyAttributes[wanted]; found {
matched++
}
}
if matched >= len(c.AttributeRewardRates) {
matched = len(c.AttributeRewardRates) - 1
}
bonus := 1.0
if matched >= 0 && len(c.AttributeRewardRates) > 0 {
bonus = c.AttributeRewardRates[matched]
}
// The current EXE rounds the float32 product, then truncates after the
// extra rate. Character levels are not an input to this formula.
product := float32(float32(rate) * float32(c.PartyMemberRewardRates[len(members)]))
product = float32(product * float32(bonus))
rounded := math.Trunc(float64(float32(product + 0.5)))
value := math.Trunc(float64(float32(float32(rounded) * float32(c.ExpeditionExtraRate))))
if math.IsNaN(value) || math.IsInf(value, 0) || value < 0 {
return 0, false
}
if value > math.MaxUint32 {
return math.MaxUint32, true
}
return uint32(value), true
}
type ExpeditionMemberInput struct {
Level int
Job byte
GrowType byte
}
func loadRuntimeConfig(ctx context.Context, source dnfpvf.Source) (RuntimeConfig, error) {
systemText, err := source.ReadText(AdventureSystem2018Path)
if err != nil {
return RuntimeConfig{}, fmt.Errorf("read dnf adventure-group PVF %q: %w", AdventureSystem2018Path, err)
}
systemDoc, err := dnfpvf.Parse(AdventureSystem2018Path, systemText)
if err != nil {
return RuntimeConfig{}, fmt.Errorf("parse dnf adventure-group PVF %q: %w", AdventureSystem2018Path, err)
}
expeditionText, err := source.ReadText(ExpeditionSystemPath)
if err != nil {
return RuntimeConfig{}, fmt.Errorf("read dnf adventure-group PVF %q: %w", ExpeditionSystemPath, err)
}
expeditionDoc, err := dnfpvf.Parse(ExpeditionSystemPath, expeditionText)
if err != nil {
return RuntimeConfig{}, fmt.Errorf("parse dnf adventure-group PVF %q: %w", ExpeditionSystemPath, err)
}
if ctx != nil {
if err := ctx.Err(); err != nil {
return RuntimeConfig{}, err
}
}
shops, err := parseShopCategories(systemDoc)
if err != nil {
return RuntimeConfig{}, err
}
growth, err := parseGrowthConfig(systemDoc)
if err != nil {
return RuntimeConfig{}, err
}
capsule, err := parseCapsule(systemDoc)
if err != nil {
return RuntimeConfig{}, err
}
areas, err := parseExpeditionAreas(expeditionDoc)
if err != nil {
return RuntimeConfig{}, err
}
professionClasses, professionGroupCount, err := parseProfessionClassifications(systemDoc)
if err != nil {
return RuntimeConfig{}, err
}
config := RuntimeConfig{
ShopCategories: shops,
Growth: growth,
Capsule: capsule,
ExpeditionAreas: areas,
AttributeIDs: parseAttributeIDs(expeditionDoc),
AttributeGroups: parseAttributeGroups(expeditionDoc),
CharacterAttributes: parseCharacterAttributes(expeditionDoc),
RotationDays: int(firstSectionInt(expeditionDoc, "area attribute rotation period")),
professionClasses: professionClasses,
professionGroupCount: professionGroupCount,
}
config.AttributeRewardRates, err = sectionFloats(expeditionDoc, "attribute achieve rate")
if err != nil || len(config.AttributeRewardRates) == 0 {
return RuntimeConfig{}, fmt.Errorf("%w: %s [attribute achieve rate]", ErrTableMalformed, ExpeditionSystemPath)
}
config.PartyMemberRewardRates, err = sectionFloats(expeditionDoc, "party member rate")
if err != nil || len(config.PartyMemberRewardRates) != 5 {
return RuntimeConfig{}, fmt.Errorf("%w: %s [party member rate]", ErrTableMalformed, ExpeditionSystemPath)
}
extraRates, err := sectionFloats(expeditionDoc, "extra rate")
if err != nil || len(extraRates) != 1 {
return RuntimeConfig{}, fmt.Errorf("%w: %s [extra rate]", ErrTableMalformed, ExpeditionSystemPath)
}
config.ExpeditionExtraRate = extraRates[0]
if len(config.AttributeIDs) == 0 || len(config.AttributeGroups) == 0 || len(config.CharacterAttributes) == 0 {
return RuntimeConfig{}, fmt.Errorf("%w: %s attribute configuration", ErrTableMalformed, ExpeditionSystemPath)
}
return config, nil
}
var adventureProfessionJobIDs = map[string]byte{
"[swordman]": 0,
"[fighter]": 1,
"[gunner]": 2,
"[mage]": 3,
"[priest]": 4,
"[at gunner]": 5,
"[thief]": 6,
"[at fighter]": 7,
"[at mage]": 8,
"[demonic swordman]": 9,
"[creator mage]": 10,
"[at swordman]": 11,
"[knight]": 12,
"[demonic lancer]": 13,
"[at priest]": 14,
"[gun blader]": 15,
}
func parseProfessionClassifications(doc *dnfpvf.Document) (map[[2]byte]byte, int, error) {
result := make(map[[2]byte]byte)
if doc == nil {
return result, 0, nil
}
insideList := false
currentGroup := -1
groupCount := 0
infoCount := 0
for _, section := range doc.Sections {
switch normalizedPVFName(section.Name) {
case "expert list":
insideList = true
currentGroup = -1
case "/expert list":
if !insideList {
continue
}
if currentGroup >= 0 && infoCount == 0 {
return nil, 0, fmt.Errorf("%w: %s [expert list] group=%d has no [info]", ErrTableMalformed, AdventureSystem2018Path, currentGroup)
}
// The group count bounds the profession classification space, so it
// is checked against ProfessionTypeCount (the 20-row descriptor
// block), NOT ContentTypeCount (the 6 u32 content counters).
if groupCount == 0 || groupCount > ProfessionTypeCount {
return nil, 0, fmt.Errorf("%w: %s [expert list] groups=%d", ErrTableMalformed, AdventureSystem2018Path, groupCount)
}
return result, groupCount, nil
case "group":
if !insideList {
continue
}
if currentGroup >= 0 && infoCount == 0 {
return nil, 0, fmt.Errorf("%w: %s [expert list] group=%d has no [info]", ErrTableMalformed, AdventureSystem2018Path, currentGroup)
}
tokens := sectionTokens(doc, section)
ordinal := int(firstInt(tokens))
if ordinal != groupCount || ordinal > math.MaxUint8 {
return nil, 0, fmt.Errorf("%w: %s [expert list] group ordinal=%d want=%d", ErrTableMalformed, AdventureSystem2018Path, ordinal, groupCount)
}
currentGroup = ordinal
groupCount++
infoCount = 0
case "info":
if !insideList || currentGroup < 0 {
continue
}
tokens := sectionTokens(doc, section)
if len(tokens) != 3 || tokens[0].Kind != dnfpvf.TokenString ||
tokens[1].Kind != dnfpvf.TokenString || tokens[2].Kind != dnfpvf.TokenInt {
return nil, 0, fmt.Errorf("%w: %s [expert list] group=%d malformed [info]", ErrTableMalformed, AdventureSystem2018Path, currentGroup)
}
job, found := adventureProfessionJobIDs[normalizedPVFName(tokens[0].Value)]
awakening := normalizedPVFName(tokens[1].Value)
grow := tokens[2].Int
if !found || (awakening != "second awakening" && awakening != "second realization") ||
grow <= 0 || grow > math.MaxUint8 {
return nil, 0, fmt.Errorf("%w: %s [expert list] group=%d invalid [info]", ErrTableMalformed, AdventureSystem2018Path, currentGroup)
}
key := [2]byte{job, byte(grow)}
if _, duplicate := result[key]; duplicate {
return nil, 0, fmt.Errorf("%w: %s [expert list] duplicate job=%d grow=%d", ErrTableMalformed, AdventureSystem2018Path, job, grow)
}
result[key] = byte(currentGroup)
infoCount++
}
}
// Synthetic progression tables may omit [expert list]. Production uses the
// current real PVF and therefore exposes the mapping to representative flows.
if !insideList {
return result, 0, nil
}
return nil, 0, fmt.Errorf("%w: %s unterminated [expert list]", ErrTableMalformed, AdventureSystem2018Path)
}
func parseShopCategories(doc *dnfpvf.Document) ([]ShopCategory, error) {
var categories []ShopCategory
for index := 0; index < len(doc.Sections); index++ {
if !strings.EqualFold(strings.TrimSpace(doc.Sections[index].Name), "shop") {
continue
}
end := index + 1
for end < len(doc.Sections) && !strings.EqualFold(strings.TrimSpace(doc.Sections[end].Name), "/shop") {
end++
}
category := ShopCategory{}
for cursor := index + 1; cursor < end; cursor++ {
section := doc.Sections[cursor]
tokens := sectionTokens(doc, section)
switch normalizedPVFName(section.Name) {
case "index":
category.Index = byte(firstInt(tokens))
case "max shop point":
category.MaxPoint = uint32(firstInt(tokens))
case "reset point":
category.ResetPointMonthly = true
case "exp to get shop point":
values := ints(tokens)
if len(values) == 2 && values[0] > 0 && values[1] > 0 {
category.ExperiencePerPoint = uint64(values[0])
category.PointPerExperience = uint32(values[1])
}
case "adventurer shop purchase info":
values := ints(tokens)
if len(values)%5 != 0 {
return nil, fmt.Errorf("%w: %s shop=%d purchase integers=%d", ErrTableMalformed, AdventureSystem2018Path, category.Index, len(values))
}
for offset := 0; offset < len(values); offset += 5 {
if values[offset] <= 0 || values[offset+1] < 0 || values[offset+2] <= 0 || values[offset+3] <= 0 {
return nil, fmt.Errorf("%w: %s shop=%d purchase row=%d", ErrTableMalformed, AdventureSystem2018Path, category.Index, offset/5)
}
category.Products = append(category.Products, ShopProduct{
ItemID: uint32(values[offset]),
RequiredManageLevel: int(values[offset+1]),
Cost: uint32(values[offset+2]),
MonthlyPurchaseLimit: uint32(values[offset+3]),
ResetType: int(values[offset+4]),
})
}
}
}
if len(category.Products) > 0 {
categories = append(categories, category)
}
index = end
}
sort.Slice(categories, func(i, j int) bool { return categories[i].Index < categories[j].Index })
if len(categories) < ShopPointTypeCount {
return nil, fmt.Errorf("%w: %s shop categories=%d", ErrTableEmpty, AdventureSystem2018Path, len(categories))
}
return categories, nil
}
func parseCapsule(doc *dnfpvf.Document) (CapsuleConfig, error) {
config := CapsuleConfig{
MinimumExperience: uint64(firstSectionInt(doc, "capsule need minimum exp")),
MaximumExperience: uint64(firstSectionInt(doc, "capsule max exp")),
MaximumCount: uint32(firstSectionInt(doc, "capsule max count")),
MinimumLevel: int(firstSectionInt(doc, "capsule limit level min")),
MaximumLevel: int(firstSectionInt(doc, "capsule limit level max")),
GrantedExperience: uint32(firstSectionInt(doc, "capsule give exp")),
}
tokens, _ := doc.Section("capsule update date")
if len(tokens) == 1 && tokens[0].Kind == dnfpvf.TokenString {
config.UpdateAt, _ = time.ParseInLocation("2006-01-02 15:04:05", tokens[0].Value, adventureGroupCalendarLocation)
}
if config.MinimumExperience == 0 || config.MaximumExperience < config.MinimumExperience ||
config.MaximumCount == 0 || config.MinimumLevel <= 0 || config.MaximumLevel < config.MinimumLevel ||
config.GrantedExperience == 0 {
return CapsuleConfig{}, fmt.Errorf("%w: %s capsule configuration", ErrTableMalformed, AdventureSystem2018Path)
}
return config, nil
}
func parseExpeditionAreas(doc *dnfpvf.Document) ([]ExpeditionArea, error) {
var areas []ExpeditionArea
for index := 0; index < len(doc.Sections); index++ {
if normalizedPVFName(doc.Sections[index].Name) != "area" {
continue
}
end := index + 1
for end < len(doc.Sections) && normalizedPVFName(doc.Sections[end].Name) != "/area" {
end++
}
area := ExpeditionArea{RewardRates: make(map[uint32]float64)}
for cursor := index + 1; cursor < end; cursor++ {
section := doc.Sections[cursor]
tokens := sectionTokens(doc, section)
switch normalizedPVFName(section.Name) {
case "index":
area.Index = byte(firstInt(tokens))
case "required level":
area.RequiredManageLevel = int(firstInt(tokens))
case "designated attribute":
for _, token := range tokens {
if token.Kind == dnfpvf.TokenString {
area.DesignatedGroups = append(area.DesignatedGroups, token.Value)
}
}
case "reward info":
if len(tokens)%2 != 0 {
return nil, fmt.Errorf("%w: %s area=%d reward tokens=%d", ErrTableMalformed, ExpeditionSystemPath, area.Index, len(tokens))
}
for offset := 0; offset < len(tokens); offset += 2 {
hours, rate := tokenNumber(tokens[offset]), tokenNumber(tokens[offset+1])
if hours <= 0 || rate <= 0 {
return nil, fmt.Errorf("%w: %s area=%d reward row=%d", ErrTableMalformed, ExpeditionSystemPath, area.Index, offset/2)
}
area.RewardRates[uint32(hours*3600)] = rate
}
}
}
if area.Index > 0 && area.RequiredManageLevel > 0 && len(area.RewardRates) > 0 {
areas = append(areas, area)
}
index = end
}
sort.Slice(areas, func(i, j int) bool { return areas[i].Index < areas[j].Index })
if len(areas) == 0 {
return nil, fmt.Errorf("%w: %s expedition areas", ErrTableEmpty, ExpeditionSystemPath)
}
return areas, nil
}
func parseAttributeIDs(doc *dnfpvf.Document) map[string]byte {
out := make(map[string]byte)
tokens, _ := doc.Section("attribute data")
for index := 0; index+1 < len(tokens); {
if tokens[index].Kind != dnfpvf.TokenInt || tokens[index+1].Kind != dnfpvf.TokenString {
index++
continue
}
id := tokens[index].Int
name := normalizedPVFName(tokens[index+1].Value)
if id > 0 && id <= math.MaxUint8 && id != math.MaxUint8 {
out[name] = byte(id)
}
index += 2
for index < len(tokens) && tokens[index].Kind != dnfpvf.TokenInt {
index++
}
if index < len(tokens) {
index++
}
}
return out
}
func parseAttributeGroups(doc *dnfpvf.Document) map[string][]string {
out := make(map[string][]string)
for _, section := range doc.Sections {
if normalizedPVFName(section.Name) != "group" {
continue
}
tokens := sectionTokens(doc, section)
if len(tokens) < 2 || tokens[0].Kind != dnfpvf.TokenString {
continue
}
key := normalizedPVFName(tokens[0].Value)
for _, token := range tokens[1:] {
if token.Kind == dnfpvf.TokenString {
out[key] = append(out[key], token.Value)
}
}
}
if groupC := out[normalizedPVFName("[group C]")]; len(groupC) > 0 {
out[normalizedPVFName("[GROUP C ALLOW]")] = append([]string(nil), groupC...)
}
return out
}
func parseCharacterAttributes(doc *dnfpvf.Document) map[[2]byte][]string {
out := make(map[[2]byte][]string)
tokens, _ := doc.Section("character attribute")
for index := 0; index+5 < len(tokens); index += 6 {
if tokens[index].Kind != dnfpvf.TokenInt || tokens[index+1].Kind != dnfpvf.TokenInt {
continue
}
key := [2]byte{byte(tokens[index].Int), byte(tokens[index+1].Int)}
for _, token := range tokens[index+3 : index+6] {
if token.Kind == dnfpvf.TokenString {
out[key] = append(out[key], token.Value)
}
}
}
return out
}
func sectionTokens(doc *dnfpvf.Document, section dnfpvf.Section) []dnfpvf.Token {
if doc == nil || section.Start < 0 || section.End > len(doc.Tokens) || section.Start > section.End {
return nil
}
return doc.Tokens[section.Start:section.End]
}
func firstSectionInt(doc *dnfpvf.Document, name string) int64 {
tokens, _ := doc.Section(name)
return firstInt(tokens)
}
func firstInt(tokens []dnfpvf.Token) int64 {
for _, token := range tokens {
if token.Kind == dnfpvf.TokenInt {
return token.Int
}
}
return 0
}
func ints(tokens []dnfpvf.Token) []int64 {
values := make([]int64, 0, len(tokens))
for _, token := range tokens {
if token.Kind == dnfpvf.TokenInt {
values = append(values, token.Int)
}
}
return values
}
func sectionFloats(doc *dnfpvf.Document, name string) ([]float64, error) {
tokens, ok := doc.Section(name)
if !ok {
return nil, ErrTableEmpty
}
values := make([]float64, 0, len(tokens))
for _, token := range tokens {
value := tokenNumber(token)
if value <= 0 {
return nil, ErrTableMalformed
}
values = append(values, value)
}
return values, nil
}
func tokenNumber(token dnfpvf.Token) float64 {
switch token.Kind {
case dnfpvf.TokenInt:
return float64(token.Int)
case dnfpvf.TokenFloat:
return token.Float
default:
return 0
}
}
func normalizedPVFName(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
@@ -0,0 +1,116 @@
package adventuregroup
import (
"fmt"
"strings"
"testing"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
)
// The live adventurersystem2018.etc ships 15 expert groups, and that count
// bounds the 20-row profession descriptor block (ProfessionTypeCount) rather
// than the 6 u32 content counters (ContentTypeCount). Confusing the two made
// the table look malformed and the server refused to start against the real
// PVF while every fixture-based test still passed, so this pins the real
// group count.
func TestParseProfessionClassificationsAcceptsTheFullNativeGroupCount(t *testing.T) {
var b strings.Builder
b.WriteString("[expert list]\n")
for group := 0; group < 15; group++ {
b.WriteString("[group]\n")
fmt.Fprintf(&b, "%d `group %d`\n", group, group)
b.WriteString("[info]\n")
fmt.Fprintf(&b, "`[swordman]` `second awakening` %d\n", group+1)
b.WriteString("[/info]\n")
b.WriteString("[/group]\n")
}
b.WriteString("[/expert list]")
document, err := dnfpvf.Parse(AdventureSystem2018Path, b.String())
if err != nil {
t.Fatal(err)
}
_, groups, err := parseProfessionClassifications(document)
if err != nil {
t.Fatalf("15 native groups rejected: %v", err)
}
if groups != 15 {
t.Fatalf("groups=%d, want 15", groups)
}
if groups > ProfessionTypeCount {
t.Fatalf("ProfessionTypeCount=%d cannot hold %d native groups", ProfessionTypeCount, groups)
}
}
func TestParseProfessionClassificationsUsesOrderedExpertGroups(t *testing.T) {
document, err := dnfpvf.Parse(AdventureSystem2018Path, `[expert list]
[group]
0 `+"`鬼剑士 (男)`"+`
[info]
`+"`[swordman]` `second awakening` 1"+`
[/info]
[info]
`+"`[swordman]` `second awakening` 4"+`
[/info]
[/group]
[group]
1 `+"`外传`"+`
[info]
`+"`[creator mage]` `second realization` 1"+`
[/info]
[info]
`+"`[demonic swordman]` `second realization` 1"+`
[/info]
[/group]
[/expert list]`)
if err != nil {
t.Fatal(err)
}
classes, groups, err := parseProfessionClassifications(document)
if err != nil {
t.Fatal(err)
}
config := RuntimeConfig{professionClasses: classes, professionGroupCount: groups}
if config.ProfessionGroupCount() != 2 {
t.Fatalf("groups=%d", config.ProfessionGroupCount())
}
for _, test := range []struct {
job, grow int
want byte
}{
{job: 0, grow: 1, want: 0},
{job: 0, grow: 4, want: 0},
{job: 10, grow: 1, want: 1},
{job: 9, grow: 1, want: 1},
} {
got, found := config.ProfessionClassification(test.job, test.grow)
if !found || got != test.want {
t.Fatalf("job=%d grow=%d classification=%d/%t want=%d", test.job, test.grow, got, found, test.want)
}
}
if _, found := config.ProfessionClassification(0, 2); found {
t.Fatal("undefined profession slot resolved")
}
clone := config.Clone()
clone.professionClasses[[2]byte{0, 1}] = 9
if original, _ := config.ProfessionClassification(0, 1); original != 0 {
t.Fatal("runtime clone shares profession map")
}
}
func TestParseProfessionClassificationsRejectsMalformedOrder(t *testing.T) {
document, err := dnfpvf.Parse(AdventureSystem2018Path, `[expert list]
[group]
1 `+"`bad ordinal`"+`
[info]
`+"`[swordman]` `second awakening` 1"+`
[/info]
[/group]
[/expert list]`)
if err != nil {
t.Fatal(err)
}
if _, _, err := parseProfessionClassifications(document); err == nil {
t.Fatal("malformed group order accepted")
}
}
@@ -0,0 +1,263 @@
package adventuregroup
import (
"encoding/json"
"errors"
"fmt"
"math"
"strconv"
"strings"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const RuntimeStateMetadataKey = "adventure_group_runtime_v1"
var (
ErrRuntimeStateInvalid = errors.New("adventure-group runtime state is invalid")
// ErrPurchaseLimitReached / ErrInsufficientShopPoints 是购买被拒的两种
// 业务原因。客户端 op1414 的结果码要按原因区分(19 = 超过购买限制、
// 22 = 购买材料不足),回一个笼统的失败码会让客户端**完全不弹提示** ——
// 所以这两种情况必须是可识别的独立错误,不能都并进 ErrRuntimeStateInvalid。
ErrPurchaseLimitReached = errors.New("adventure-group shop monthly purchase limit is reached")
ErrInsufficientShopPoints = errors.New("adventure-group shop points are insufficient")
)
type RuntimeState struct {
Version int `json:"version"`
ShopPoints [ShopPointTypeCount]uint32 `json:"shop_points"`
BraveExperience uint64 `json:"brave_experience"`
GrowthExperience uint64 `json:"growth_experience"`
Month string `json:"month"`
// CapsuleWeek 记录成长胶囊槽上一次"归属"的那一周(每周四 06:00 为界)。
// 见 Normalize:跨周才清零,且第一次见到空值是**认领当前值**而不是清零。
CapsuleWeek string `json:"capsule_week,omitempty"`
Purchases map[string]uint32 `json:"purchases,omitempty"`
Expeditions map[string]Expedition `json:"expeditions,omitempty"`
}
type Expedition struct {
Area byte `json:"area"`
State byte `json:"state"`
StartedAt int64 `json:"started_at"`
EndsAt int64 `json:"ends_at"`
Attributes []byte `json:"attributes,omitempty"`
Members []ExpeditionMember `json:"members"`
Reward uint32 `json:"reward"`
}
type ExpeditionMember struct {
// PersistentCharacterID is the durable character ID. The native client
// echoes the u16 selector, but the selector is a roster slot and moves when
// the roster is reordered, so busy detection must key on this instead.
PersistentCharacterID string `json:"persistent_character_id,omitempty"`
CharacterID uint16 `json:"character_id"`
Name string `json:"name"`
Level uint32 `json:"level"`
Job byte `json:"job"`
GrowType byte `json:"grow_type"`
Status byte `json:"status"`
}
func ParseRuntimeState(account dnfrepo.AccountRecord, config RuntimeConfig, now time.Time) (RuntimeState, error) {
state := RuntimeState{Version: 1}
value := strings.TrimSpace(account.Metadata[RuntimeStateMetadataKey])
if value != "" {
if err := json.Unmarshal([]byte(value), &state); err != nil {
return RuntimeState{}, fmt.Errorf("%w: %v", ErrRuntimeStateInvalid, err)
}
if state.Version != 1 {
return RuntimeState{}, fmt.Errorf("%w: version=%d", ErrRuntimeStateInvalid, state.Version)
}
}
if state.Purchases == nil {
state.Purchases = make(map[string]uint32)
}
if state.Expeditions == nil {
state.Expeditions = make(map[string]Expedition)
}
state.Normalize(config, now)
return state, nil
}
func (s *RuntimeState) Normalize(config RuntimeConfig, now time.Time) {
if s == nil {
return
}
s.Version = 1
month := now.In(adventureGroupCalendarLocation).Format("2006-01")
if s.Month != month {
s.Month = month
s.Purchases = make(map[string]uint32)
for _, category := range config.ShopCategories {
if category.ResetPointMonthly && int(category.Index) < len(s.ShopPoints) {
s.ShopPoints[category.Index] = 0
}
}
}
// 成长胶囊槽按周重置:客户端界面写明"成长胶囊于每周四凌晨6点重置"。
// 第一次读到(旧存档没有这个字段)只**认领**当前周,绝不清零 —— 否则升级
// 部署那一刻会把玩家攒下的一整槽胶囊静默清掉。
capsuleWeek := adventureGroupCapsuleWeek(now)
if s.CapsuleWeek == "" {
s.CapsuleWeek = capsuleWeek
} else if s.CapsuleWeek != capsuleWeek {
s.CapsuleWeek = capsuleWeek
s.GrowthExperience = 0
}
if s.Purchases == nil {
s.Purchases = make(map[string]uint32)
}
if s.Expeditions == nil {
s.Expeditions = make(map[string]Expedition)
}
for _, category := range config.ShopCategories {
if int(category.Index) < len(s.ShopPoints) && category.MaxPoint > 0 &&
s.ShopPoints[category.Index] > category.MaxPoint {
s.ShopPoints[category.Index] = category.MaxPoint
}
}
if capacity := CapsuleTotalCapacity(config.Capsule); capacity > 0 && s.GrowthExperience > capacity {
s.GrowthExperience = capacity
}
}
// adventureGroupCapsuleWeek 返回 now 所属"胶囊周"的标识:以**每周四 06:00**
// (Asia/Shanghai)为分界,标识取该周起始那一天的日期。界面文案是
// "成长胶囊于每周四凌晨6点重置。",所以周四 06:00 之前仍算上一周。
func adventureGroupCapsuleWeek(now time.Time) string {
local := now.In(adventureGroupCalendarLocation)
// time.Weekday: Sunday=0 ... Thursday=4
daysSinceThursday := (int(local.Weekday()) - int(time.Thursday) + 7) % 7
boundary := time.Date(local.Year(), local.Month(), local.Day(), 6, 0, 0, 0, adventureGroupCalendarLocation).
AddDate(0, 0, -daysSinceThursday)
if local.Before(boundary) {
boundary = boundary.AddDate(0, 0, -7)
}
return boundary.Format("2006-01-02")
}
func (s RuntimeState) Marshal() (string, error) {
data, err := json.Marshal(s)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrRuntimeStateInvalid, err)
}
return string(data), nil
}
func (s RuntimeState) PurchaseCount(category byte, itemID uint32) uint32 {
return s.Purchases[purchaseKey(category, itemID)]
}
func (s *RuntimeState) Spend(category ShopCategory, product ShopProduct) error {
if s == nil || int(category.Index) >= len(s.ShopPoints) {
return ErrRuntimeStateInvalid
}
count := s.PurchaseCount(category.Index, product.ItemID)
if count >= product.MonthlyPurchaseLimit {
return ErrPurchaseLimitReached
}
if s.ShopPoints[category.Index] < product.Cost {
return ErrInsufficientShopPoints
}
if s.Purchases == nil {
// ParseRuntimeState/Normalize 一定会建好这张表;这里只是不让手工构造的
// 状态把"记一次购买"变成 panic。
s.Purchases = make(map[string]uint32)
}
s.ShopPoints[category.Index] -= product.Cost
s.Purchases[purchaseKey(category.Index, product.ItemID)] = count + 1
return nil
}
func (s *RuntimeState) AddGlory(config RuntimeConfig, value uint32) {
if s == nil || value == 0 {
return
}
category, ok := config.Shop(ShopPointGlory)
if !ok {
return
}
s.ShopPoints[ShopPointGlory] = saturatingPointAdd(s.ShopPoints[ShopPointGlory], value, category.MaxPoint)
}
// AddExperience 记入一笔**已经发给角色**的经验。
//
// - crystalExperience(任意等级获得的经验)按 PVF `[exp to get shop point]`
// 进骁勇结晶,余数带在 BraveExperience 里,封顶 `[max shop point]`。
// 角色自己的升级经验不受影响,这只是账号账本上的镜像累计
// (2026-09-24 服主口径:没满级的角色也要能攒结晶)。
// - maximumLevelExperience(其中越过等级上限的部分)再进成长胶囊槽:胶囊槽
// 是满级专属(客户端说明写着"Lv90以上的角色获得经验值时,经验值会累积在
// 成长胶囊槽中"),未满级经验一律不进。
func (s *RuntimeState) AddExperience(
config RuntimeConfig,
crystalExperience uint64,
maximumLevelExperience uint64,
) {
if s == nil || (crystalExperience == 0 && maximumLevelExperience == 0) {
return
}
if category, ok := config.Shop(ShopPointBrave); ok && category.ExperiencePerPoint > 0 && category.PointPerExperience > 0 {
total := s.BraveExperience + crystalExperience
points := total / category.ExperiencePerPoint
s.BraveExperience = total % category.ExperiencePerPoint
if points > math.MaxUint32 {
points = math.MaxUint32
}
add := uint64(category.PointPerExperience) * points
if add > math.MaxUint32 {
add = math.MaxUint32
}
s.ShopPoints[ShopPointBrave] = saturatingPointAdd(s.ShopPoints[ShopPointBrave], uint32(add), category.MaxPoint)
}
if maximumLevelExperience == 0 {
return
}
// 胶囊槽可以攒多格:总容量 = [capsule max exp] × [capsule max count]。
// 只按单格上限截断会让累计值停在正好 100%,客户端那条进度条永远 0%
// (分子的 "累计值 mod 单格" 恒为 0),现场表现为"胶囊不累计"。
if capacity := CapsuleTotalCapacity(config.Capsule); capacity > 0 {
remaining := capacity - minUint64(capacity, s.GrowthExperience)
add := maximumLevelExperience
if add > remaining {
add = remaining
}
s.GrowthExperience += add
}
}
func (s *RuntimeState) ConsumeCapsule(config RuntimeConfig) bool {
if s == nil || config.Capsule.MinimumExperience == 0 || s.GrowthExperience < config.Capsule.MinimumExperience {
return false
}
s.GrowthExperience -= config.Capsule.MinimumExperience
return true
}
func purchaseKey(category byte, itemID uint32) string {
return strconv.Itoa(int(category)) + ":" + strconv.FormatUint(uint64(itemID), 10)
}
func ExpeditionKey(area byte) string {
return strconv.Itoa(int(area))
}
func saturatingPointAdd(current, value, maximum uint32) uint32 {
total := uint64(current) + uint64(value)
if maximum > 0 && total > uint64(maximum) {
return maximum
}
if total > math.MaxUint32 {
return math.MaxUint32
}
return uint32(total)
}
func minUint64(left, right uint64) uint64 {
if left < right {
return left
}
return right
}
@@ -0,0 +1,219 @@
package adventuregroup
import (
"errors"
"testing"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
func TestRuntimeStateMonthlyResetAccrualPurchaseAndCapsule(t *testing.T) {
config := RuntimeConfig{
ShopCategories: []ShopCategory{
{
Index: ShopPointBrave,
MaxPoint: 250,
ExperiencePerPoint: 15_000_000,
PointPerExperience: 1,
Products: []ShopProduct{{
ItemID: 10, Cost: 1, MonthlyPurchaseLimit: 2,
}},
},
{Index: ShopPointGlory, MaxPoint: 9999, ResetPointMonthly: true},
{Index: ShopPointPure},
},
Capsule: CapsuleConfig{
MinimumExperience: 10,
MaximumExperience: 100,
MaximumCount: 10,
MinimumLevel: 50,
MaximumLevel: 86,
GrantedExperience: 1,
},
}
account := dnfrepo.AccountRecord{
AccountID: "dnf:1",
Metadata: map[string]string{
RuntimeStateMetadataKey: `{"version":1,"shop_points":[7,80,0],"month":"2026-06","purchases":{"0:10":2}}`,
},
}
now := time.Date(2026, time.July, 28, 12, 0, 0, 0, time.FixedZone("UTC+8", 8*60*60))
state, err := ParseRuntimeState(account, config, now)
if err != nil {
t.Fatal(err)
}
if state.ShopPoints != [ShopPointTypeCount]uint32{7, 0, 0} || len(state.Purchases) != 0 {
t.Fatalf("monthly normalized state=%+v", state)
}
// 满级角色的整笔经验:全额进结晶,同样全额(= 越过上限的部分)进胶囊槽。
state.AddExperience(config, 30_000_005, 30_000_005)
// 胶囊总容量 = [capsule max exp] 100 × [capsule max count] 10 = 1000
// (PVF 真值:116,914,950 × 10;界面写"累积数上限 : 10个")。
// 早先这里按"单格 100 就是总上限"写,会把累计值卡死在正好 100%,
// 客户端进度分子(累计值 mod 单格)恒 0 —— 现场就是"胶囊不累计"。
if state.ShopPoints[ShopPointBrave] != 9 || state.BraveExperience != 5 || state.GrowthExperience != 1000 {
t.Fatalf("maximum-level accrual=%+v", state)
}
category, _ := config.Shop(ShopPointBrave)
if err := state.Spend(category, category.Products[0]); err != nil {
t.Fatal(err)
}
if state.ShopPoints[ShopPointBrave] != 8 || state.PurchaseCount(ShopPointBrave, 10) != 1 {
t.Fatalf("purchase state=%+v", state)
}
if !state.ConsumeCapsule(config) || state.GrowthExperience != 990 {
t.Fatalf("capsule state=%+v", state)
}
}
// 未满级经验只进骁勇结晶:胶囊槽与名誉经验都是满级专属(2026-09-24 口径)。
func TestRuntimeStateAddExperienceKeepsSubCapExperienceOutOfCapsule(t *testing.T) {
config := creditTestConfig()
state := RuntimeState{Version: 1}
state.AddExperience(config, 30_000_005, 0)
if state.ShopPoints[ShopPointBrave] != 2 || state.BraveExperience != 5 {
t.Fatalf("sub-cap crystals=%+v", state)
}
if state.GrowthExperience != 0 {
t.Fatalf("sub-cap experience entered the capsule slot: %+v", state)
}
}
// 购买被拒的两种业务原因必须是可区分的错误:客户端 op1414 的结果码要按原因回
// (19 = 超过购买限制、22 = 购买材料不足),回一个笼统失败码客户端什么都不弹。
func TestRuntimeStateSpendDistinguishesLimitAndInsufficientPoints(t *testing.T) {
product := ShopProduct{ItemID: 10, Cost: 5, MonthlyPurchaseLimit: 1}
category := ShopCategory{Index: ShopPointBrave, MaxPoint: 250, Products: []ShopProduct{product}}
state := RuntimeState{Version: 1}
// 点数不足:0 点买 5 点的商品。
if err := state.Spend(category, product); !errors.Is(err, ErrInsufficientShopPoints) {
t.Fatalf("insufficient points error=%v", err)
}
// 给够点数后先买一次成功,再用完本月次数。
state.ShopPoints[ShopPointBrave] = 10
if err := state.Spend(category, product); err != nil {
t.Fatalf("first purchase=%v", err)
}
if err := state.Spend(category, product); !errors.Is(err, ErrPurchaseLimitReached) {
t.Fatalf("limit error=%v", err)
}
// 分类号越界仍然是"状态非法",不是业务拒绝。
bad := ShopCategory{Index: ShopPointTypeCount, Products: []ShopProduct{product}}
if err := state.Spend(bad, product); !errors.Is(err, ErrRuntimeStateInvalid) {
t.Fatalf("invalid category error=%v", err)
}
}
// 成长胶囊槽按周重置:界面写明"成长胶囊于每周四凌晨6点重置"。
// 两条关键契约:① 第一次读到旧存档(没有 capsule_week 字段)**不许清零**;
// ② 跨越周四 06:00 之后才清零。
func TestRuntimeStateGrowthCapsuleResetsWeeklyOnThursdaySix(t *testing.T) {
// 上限取真实 PVF 值(116,914,950 = 10 × [capsule need minimum exp]),
// 免得归一化里的封顶把"是否被周重置"这件事掩盖掉。
config := RuntimeConfig{Capsule: CapsuleConfig{MaximumExperience: 116_914_950}}
shanghai := time.FixedZone("UTC+8", 8*60*60)
// 2026-09-23 是周三。周四 06:00 之前仍属上一周(2026-09-17)。
wednesday := time.Date(2026, time.September, 23, 12, 0, 0, 0, shanghai)
state, err := ParseRuntimeState(
dnfrepo.AccountRecord{AccountID: "dnf:1", Metadata: map[string]string{
RuntimeStateMetadataKey: `{"version":1,"growth_experience":116914950,"month":"2026-09"}`,
}},
config,
wednesday,
)
if err != nil {
t.Fatal(err)
}
// 旧存档首次归一化:认领当前值,绝不清零。
if state.GrowthExperience != 116_914_950 || state.CapsuleWeek != "2026-09-17" {
t.Fatalf("first observation reset the capsule: %+v", state)
}
// 同一周内再读一次:不动。
sameWeek, err := ParseRuntimeState(
dnfrepo.AccountRecord{AccountID: "dnf:1", Metadata: map[string]string{
RuntimeStateMetadataKey: `{"version":1,"growth_experience":500,"capsule_week":"2026-09-17"}`,
}},
config,
time.Date(2026, time.September, 23, 23, 59, 0, 0, shanghai),
)
if err != nil {
t.Fatal(err)
}
if sameWeek.GrowthExperience != 500 {
t.Fatalf("same-week state reset: %+v", sameWeek)
}
// 周四 05:59 仍是上一周;06:00 起进入新的一周 → 清零。
beforeBoundary, err := ParseRuntimeState(
dnfrepo.AccountRecord{AccountID: "dnf:1", Metadata: map[string]string{
RuntimeStateMetadataKey: `{"version":1,"growth_experience":500,"capsule_week":"2026-09-17"}`,
}},
config,
time.Date(2026, time.September, 24, 5, 59, 0, 0, shanghai),
)
if err != nil {
t.Fatal(err)
}
if beforeBoundary.GrowthExperience != 500 {
t.Fatalf("reset happened before Thursday 06:00: %+v", beforeBoundary)
}
afterBoundary, err := ParseRuntimeState(
dnfrepo.AccountRecord{AccountID: "dnf:1", Metadata: map[string]string{
RuntimeStateMetadataKey: `{"version":1,"growth_experience":500,"capsule_week":"2026-09-17"}`,
}},
config,
time.Date(2026, time.September, 24, 6, 0, 0, 0, shanghai),
)
if err != nil {
t.Fatal(err)
}
if afterBoundary.GrowthExperience != 0 || afterBoundary.CapsuleWeek != "2026-09-24" {
t.Fatalf("weekly capsule reset missing: %+v", afterBoundary)
}
// 骁勇结晶一辈子不重置,跨周也不许动。
if afterBoundary.BraveExperience != 0 || afterBoundary.ShopPoints[ShopPointBrave] != 0 {
t.Fatalf("weekly reset touched crystals: %+v", afterBoundary)
}
}
func TestRuntimeConfigExpeditionRewardUsesRotatedPVFAttributes(t *testing.T) {
config := RuntimeConfig{
ExpeditionAreas: []ExpeditionArea{{
Index: 1,
RewardRates: map[uint32]float64{
6 * 60 * 60: 2,
},
DesignatedGroups: []string{"[group A]"},
}},
AttributeIDs: map[string]byte{
"[a]": 1,
},
AttributeGroups: map[string][]string{
"[group a]": {"[a]"},
},
CharacterAttributes: map[[2]byte][]string{
{1, 2}: {"[a]"},
},
AttributeRewardRates: []float64{1, 1.5},
PartyMemberRewardRates: []float64{1, 1, 2, 3, 4},
ExpeditionExtraRate: 0.5,
RotationDays: 1,
}
// Native formula: rate 2 x party rate 1 x attribute bonus 1.5 = 3, rounded,
// then x extra rate 0.5 and truncated -> 1.
reward, ok := config.ExpeditionReward(
1,
6*60*60,
[]ExpeditionMemberInput{{Level: 90, Job: 1, GrowType: 2}},
config.AreaAttributes(1, time.Unix(1_700_000_000, 0)),
)
if !ok || reward != 1 {
t.Fatalf("reward=%d ok=%v", reward, ok)
}
}
@@ -0,0 +1,198 @@
package adventuregroup
import (
"errors"
"fmt"
"math"
)
const CharacterManagementPath = "etc/linksystem/charactermanage.etc"
const (
AdventureSystem2018Path = "etc/adventurersystem/adventurersystem2018.etc"
ExpeditionSystemPath = "etc/adventurersystem/adventurerexpeditionsystem.etc"
)
var (
ErrTableEmpty = errors.New("dnf adventure-group PVF table is empty")
ErrTableMalformed = errors.New("dnf adventure-group PVF table is malformed")
ErrCharacterLevel = errors.New("dnf adventure-group character level is invalid")
ErrPointOverflow = errors.New("dnf adventure-group point total overflows uint64")
)
// Character contains only the account-roster state needed by the adventure
// group calculation. Deleted characters never contribute points.
type Character struct {
Level int
Deleted bool
}
// Benefits are exact lookups for one PVF management level. A missing table row
// is represented by zero; values are not inherited from an earlier level.
type Benefits struct {
ExpBonusPercent uint64
GoldBonusPercent uint64
ManageOption uint64
}
// Summary is the pure account-wide result derived from character levels and
// the current runtime PVF tables.
type Summary struct {
TotalPoint uint64
ManageLevel int
Benefits
}
// Snapshot reports typed source coverage without exposing mutable table maps.
type Snapshot struct {
PointRanges int
ManageThresholds int
ManageLevelMax int
ExpBonusLevels int
GoldBonusLevels int
ManageOptionLevels int
}
type pointRange struct {
minLevel int
maxLevel int
point uint64
}
// Tables is an immutable snapshot of etc/linksystem/charactermanage.etc.
type Tables struct {
pointRanges []pointRange
manageThresholds []uint64
manageLevelMax int
expBonusByLevel map[int]uint64
goldBonusByLevel map[int]uint64
optionByLevel map[int]uint64
runtime RuntimeConfig
}
// Snapshot returns table coverage counts useful for startup diagnostics and
// real-PVF smoke tests.
func (t *Tables) Snapshot() Snapshot {
if t == nil {
return Snapshot{}
}
return Snapshot{
PointRanges: len(t.pointRanges),
ManageThresholds: len(t.manageThresholds),
ManageLevelMax: t.manageLevelMax,
ExpBonusLevels: len(t.expBonusByLevel),
GoldBonusLevels: len(t.goldBonusByLevel),
ManageOptionLevels: len(t.optionByLevel),
}
}
// Runtime returns a detached copy of the current PVF-backed adventure-group
// expedition, shop and growth-capsule configuration.
func (t *Tables) Runtime() RuntimeConfig {
if t == nil {
return RuntimeConfig{}
}
return t.runtime.Clone()
}
// CharacterPoint sums every per-level point value whose level is at or below
// the character's level. PVF point ranges may contain gaps.
func (t *Tables) CharacterPoint(level int) (uint64, error) {
if t == nil || len(t.pointRanges) == 0 {
return 0, ErrTableEmpty
}
if level <= 0 {
return 0, fmt.Errorf("%w: %d", ErrCharacterLevel, level)
}
var total uint64
for _, row := range t.pointRanges {
if level < row.minLevel {
break
}
end := level
if end > row.maxLevel {
end = row.maxLevel
}
count := uint64(end - row.minLevel + 1)
if row.point != 0 && count > math.MaxUint64/row.point {
return 0, fmt.Errorf("%w: level=%d range=%d..%d", ErrPointOverflow, level, row.minLevel, row.maxLevel)
}
contribution := count * row.point
if total > math.MaxUint64-contribution {
return 0, fmt.Errorf("%w: level=%d", ErrPointOverflow, level)
}
total += contribution
}
return total, nil
}
// ManageLevelForPoint resolves passed thresholds in order, then applies the
// explicit PVF management-level maximum. If PVF provides fewer thresholds than
// that maximum, the highest reachable level remains the last threshold.
func (t *Tables) ManageLevelForPoint(totalPoint uint64) int {
if t == nil {
return 0
}
level := 0
for _, threshold := range t.manageThresholds {
if totalPoint < threshold {
break
}
level++
}
if level > t.manageLevelMax {
return t.manageLevelMax
}
return level
}
// BenefitsForLevel performs exact PVF table lookups. Sparse tables do not
// inherit the previous level's values.
func (t *Tables) BenefitsForLevel(manageLevel int) Benefits {
if t == nil || manageLevel <= 0 {
return Benefits{}
}
return Benefits{
ExpBonusPercent: t.expBonusByLevel[manageLevel],
GoldBonusPercent: t.goldBonusByLevel[manageLevel],
ManageOption: t.optionByLevel[manageLevel],
}
}
// Calculate derives one account summary from lightweight character rows.
func (t *Tables) Calculate(characters []Character) (Summary, error) {
if t == nil || len(t.pointRanges) == 0 {
return Summary{}, ErrTableEmpty
}
var total uint64
for _, character := range characters {
if character.Deleted {
continue
}
point, err := t.CharacterPoint(character.Level)
if err != nil {
return Summary{}, err
}
if total > math.MaxUint64-point {
return Summary{}, fmt.Errorf("%w: account roster", ErrPointOverflow)
}
total += point
}
level := t.ManageLevelForPoint(total)
return Summary{
TotalPoint: total,
ManageLevel: level,
Benefits: t.BenefitsForLevel(level),
}, nil
}
// CalculateLevels is a convenience for callers that already filtered deleted
// characters and only have an account's character levels.
func (t *Tables) CalculateLevels(levels []int) (Summary, error) {
characters := make([]Character, len(levels))
for index, level := range levels {
characters[index] = Character{Level: level}
}
return t.Calculate(characters)
}
@@ -0,0 +1,290 @@
package adventuregroup
import (
"context"
"errors"
"fmt"
"math"
"os"
"strings"
"testing"
platformpvf "longheng.io/server/internal/platform/pvf"
)
func TestCalculateUsesCumulativePerLevelPointsAndExactBenefits(t *testing.T) {
tables, err := Load(context.Background(), adventureMemorySource{
CharacterManagementPath: managementDocument(
"2 3 10 5 5 20",
"10 30 50",
"2",
"1 5 2 10",
"1 2 2 4",
"1 7 2 9",
),
})
if err != nil {
t.Fatal(err)
}
point, err := tables.CharacterPoint(5)
if err != nil {
t.Fatal(err)
}
if point != 40 {
t.Fatalf("level-5 point = %d, want 40", point)
}
summary, err := tables.Calculate([]Character{
{Level: 1},
{Level: 2},
{Level: 3},
{Level: 5},
{Level: 5, Deleted: true},
})
if err != nil {
t.Fatal(err)
}
if summary.TotalPoint != 70 || summary.ManageLevel != 2 {
t.Fatalf("summary = %+v, want total=70 level=2", summary)
}
if summary.ExpBonusPercent != 10 || summary.GoldBonusPercent != 4 || summary.ManageOption != 9 {
t.Fatalf("benefits = %+v", summary.Benefits)
}
if got := tables.BenefitsForLevel(3); got != (Benefits{}) {
t.Fatalf("sparse level inherited benefits: %+v", got)
}
if got := tables.ManageLevelForPoint(9); got != 0 {
t.Fatalf("point 9 level = %d, want 0", got)
}
if got := tables.ManageLevelForPoint(10); got != 1 {
t.Fatalf("point 10 level = %d, want 1", got)
}
if got := tables.ManageLevelForPoint(50); got != 2 {
t.Fatalf("max-capped level = %d, want 2", got)
}
levelsSummary, err := tables.CalculateLevels([]int{2, 3, 5})
if err != nil {
t.Fatal(err)
}
if levelsSummary.TotalPoint != 70 || levelsSummary.ManageLevel != 2 {
t.Fatalf("level-only summary = %+v", levelsSummary)
}
if got := tables.Snapshot(); got != (Snapshot{PointRanges: 2, ManageThresholds: 3, ManageLevelMax: 2, ExpBonusLevels: 2, GoldBonusLevels: 2, ManageOptionLevels: 2}) {
t.Fatalf("snapshot = %+v", got)
}
}
func TestCurrentShapeAllowsEmptyExpAndGoldTables(t *testing.T) {
tables, err := Load(context.Background(), adventureMemorySource{
CharacterManagementPath: managementDocument("40 40 100", "100", "1", "", "", "1 10"),
})
if err != nil {
t.Fatal(err)
}
summary, err := tables.CalculateLevels([]int{40})
if err != nil {
t.Fatal(err)
}
if summary != (Summary{TotalPoint: 100, ManageLevel: 1, Benefits: Benefits{ManageOption: 10}}) {
t.Fatalf("summary = %+v", summary)
}
}
func TestLoadRejectsMalformedTypedSections(t *testing.T) {
valid := managementDocument("2 3 10", "10 30", "2", "1 5", "1 2", "1 7")
tests := []struct {
name string
doc string
want error
}{
{name: "missing section", doc: strings.Replace(valid, "[exp bonus]\n1 5\n[/exp bonus]\n", "", 1), want: ErrTableEmpty},
{name: "duplicate section", doc: valid + "\n[gold bonus]\n[/gold bonus]\n", want: ErrTableMalformed},
{name: "point tuple", doc: managementDocument("2 3", "10 30", "2", "1 5", "1 2", "1 7"), want: ErrTableMalformed},
{name: "point overlap", doc: managementDocument("2 3 10 3 4 20", "10 30", "2", "1 5", "1 2", "1 7"), want: ErrTableMalformed},
{name: "negative point", doc: managementDocument("2 3 -1", "10 30", "2", "1 5", "1 2", "1 7"), want: ErrTableMalformed},
{name: "threshold order", doc: managementDocument("2 3 10", "30 10", "2", "1 5", "1 2", "1 7"), want: ErrTableMalformed},
{name: "max arity", doc: managementDocument("2 3 10", "10 30", "2 3", "1 5", "1 2", "1 7"), want: ErrTableMalformed},
{name: "odd pair", doc: managementDocument("2 3 10", "10 30", "2", "1", "1 2", "1 7"), want: ErrTableMalformed},
{name: "duplicate pair level", doc: managementDocument("2 3 10", "10 30", "2", "1 5 1 6", "1 2", "1 7"), want: ErrTableMalformed},
{name: "pair level above max", doc: managementDocument("2 3 10", "10 30", "2", "3 5", "1 2", "1 7"), want: ErrTableMalformed},
{name: "non integer", doc: managementDocument("2 3 10", "10 30", "2", "1 5.5", "1 2", "1 7"), want: ErrTableMalformed},
{name: "empty option", doc: managementDocument("2 3 10", "10 30", "2", "1 5", "1 2", ""), want: ErrTableEmpty},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := Load(context.Background(), adventureMemorySource{CharacterManagementPath: test.doc})
if !errors.Is(err, test.want) {
t.Fatalf("Load error = %v, want %v", err, test.want)
}
})
}
}
func TestCalculateRejectsInvalidLevelsAndPointOverflow(t *testing.T) {
tables, err := Load(context.Background(), adventureMemorySource{
CharacterManagementPath: managementDocument("1 1 10", "10", "1", "", "", "1 10"),
})
if err != nil {
t.Fatal(err)
}
if _, err := tables.Calculate([]Character{{Level: 0}}); !errors.Is(err, ErrCharacterLevel) {
t.Fatalf("zero-level error = %v", err)
}
if summary, err := tables.Calculate([]Character{{Level: 0, Deleted: true}}); err != nil || summary != (Summary{}) {
t.Fatalf("deleted invalid row should not contribute: summary=%+v err=%v", summary, err)
}
largePoint := fmt.Sprintf("1 1 %d", int64(math.MaxInt64))
overflowTables, err := Load(context.Background(), adventureMemorySource{
CharacterManagementPath: managementDocument(largePoint, "1", "1", "", "", "1 1"),
})
if err != nil {
t.Fatal(err)
}
if _, err := overflowTables.CalculateLevels([]int{1, 1, 1}); !errors.Is(err, ErrPointOverflow) {
t.Fatalf("roster overflow error = %v", err)
}
rangePoint := fmt.Sprintf("1 3 %d", int64(math.MaxInt64))
rangeTables, err := Load(context.Background(), adventureMemorySource{
CharacterManagementPath: managementDocument(rangePoint, "1", "1", "", "", "1 1"),
})
if err != nil {
t.Fatal(err)
}
if _, err := rangeTables.CharacterPoint(3); !errors.Is(err, ErrPointOverflow) {
t.Fatalf("range overflow error = %v", err)
}
}
func TestLoadHonorsContextAndSourceErrors(t *testing.T) {
if _, err := Load(context.Background(), nil); err == nil {
t.Fatal("nil source accepted")
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := Load(ctx, adventureMemorySource{}); !errors.Is(err, context.Canceled) {
t.Fatalf("cancelled context error = %v", err)
}
if _, err := Load(context.Background(), adventureMemorySource{}); !errors.Is(err, platformpvf.ErrFileNotFound) {
t.Fatalf("missing source error = %v", err)
}
}
func TestRealScriptPVFAdventureGroupTables(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify real adventure-group tables")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatal(err)
}
tables, err := LoadComplete(context.Background(), archive)
if err != nil {
t.Fatal(err)
}
snapshot := tables.Snapshot()
wantSnapshot := Snapshot{
PointRanges: 61,
ManageThresholds: 13,
ManageLevelMax: 17,
ExpBonusLevels: 0,
GoldBonusLevels: 0,
ManageOptionLevels: 11,
}
if snapshot != wantSnapshot {
t.Fatalf("real snapshot = %+v, want %+v", snapshot, wantSnapshot)
}
runtime := tables.Runtime()
if len(runtime.ShopCategories) != 3 || len(runtime.ExpeditionAreas) != 4 ||
runtime.Capsule.MinimumExperience != 11691495 ||
runtime.Capsule.GrantedExperience != 1000000 {
t.Fatalf("real runtime config = %+v", runtime)
}
if runtime.ProfessionGroupCount() != 15 {
t.Fatalf("real profession groups=%d, want 15", runtime.ProfessionGroupCount())
}
for _, test := range []struct {
job, grow int
want byte
}{
{job: 0, grow: 4, want: 0},
{job: 11, grow: 4, want: 1},
{job: 7, grow: 4, want: 2},
{job: 1, grow: 4, want: 3},
{job: 10, grow: 1, want: 14},
{job: 9, grow: 1, want: 14},
} {
got, found := runtime.ProfessionClassification(test.job, test.grow)
if !found || got != test.want {
t.Fatalf("real classification job=%d grow=%d got=%d/%t want=%d", test.job, test.grow, got, found, test.want)
}
}
point, err := tables.CharacterPoint(86)
if err != nil {
t.Fatal(err)
}
if point != 10785 {
t.Fatalf("real Lv86 point = %d, want 10785", point)
}
one, err := tables.CalculateLevels([]int{86})
if err != nil {
t.Fatal(err)
}
if one != (Summary{TotalPoint: 10785, ManageLevel: 2, Benefits: Benefits{ManageOption: 30}}) {
t.Fatalf("real one-Lv86 summary = %+v", one)
}
eight, err := tables.CalculateLevels([]int{86, 86, 86, 86, 86, 86, 86, 86})
if err != nil {
t.Fatal(err)
}
if eight != (Summary{TotalPoint: 86280, ManageLevel: 7, Benefits: Benefits{ManageOption: 125}}) {
t.Fatalf("real eight-Lv86 summary = %+v", eight)
}
currentAccount, err := tables.CalculateLevels([]int{86, 90, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1})
if err != nil {
t.Fatal(err)
}
if currentAccount != (Summary{TotalPoint: 23310, ManageLevel: 4, Benefits: Benefits{ManageOption: 70}}) {
t.Fatalf("real current-account 16-character summary = %+v", currentAccount)
}
zero, err := tables.Calculate([]Character{{Level: 100, Deleted: true}})
if err != nil || zero != (Summary{}) {
t.Fatalf("real deleted-only summary = %+v err=%v", zero, err)
}
t.Logf("real adventure-group snapshot=%+v oneLv86=%+v eightLv86=%+v currentAccount=%+v", snapshot, one, eight, currentAccount)
}
type adventureMemorySource map[string]string
func (s adventureMemorySource) ReadText(path string) (string, error) {
value, ok := s[path]
if !ok {
return "", fmt.Errorf("%w: %s", platformpvf.ErrFileNotFound, path)
}
return value, nil
}
func managementDocument(point, thresholds, maxLevel, exp, gold, option string) string {
return fmt.Sprintf(`[point bonus]
%s
[/point bonus]
[manage level point]
%s
[/manage level point]
[manage level max]
%s
[exp bonus]
%s
[/exp bonus]
[gold bonus]
%s
[/gold bonus]
[manage option]
%s
[/manage option]
`, point, thresholds, maxLevel, exp, gold, option)
}
@@ -0,0 +1,5 @@
// Package alignedcmd 负责把当前 EXE 已确认协议号一致的 C2S 命令归入业务模块。
//
// 本包只做协议迁移边界判断,不直接读写角色、物品、金币或任务状态。
// 具体业务必须在对应 owner/repository/handler 补齐 EXE 包体证据后再实现。
package alignedcmd
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,30 @@
package alignedcmd
import (
"strconv"
"longheng.io/server/internal/modules/dnf/jobmap"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// MagicBoxGroupsForCharacter 过滤掉与角色职业/等级不匹配的 `[charactor]` 组。
//
// [booster info] 里一个 `[charactor] \`[swordman]\` 1 99 1 <item> <weight> <count>`
// 就是一组"只发给该职业"的奖励。礼盒一旦按职业分支(例如豪华婚礼化妆箱:鬼剑士
// 拿男鬼剑时装、女鬼剑士拿 at swordman 那组),就**只能发开启者自己那一组**;
// 不过滤的话会把所有职业的时装/道具一起发出去。
//
// Job 为空的分组(普通 [etc]/[stackable]/[avatar] 组)不受影响,原样保留。
func MagicBoxGroupsForCharacter(groups []MagicBoxRewardGroup, character dnfrepo.CharacterRecord) []MagicBoxRewardGroup {
job, err := strconv.Atoi(character.Job)
result := make([]MagicBoxRewardGroup, 0, len(groups))
for _, group := range groups {
if group.Job != "" &&
(err != nil || !jobmap.MatchesQuestTag(group.Job, job) ||
int64(character.Level) < group.MinLevel || int64(character.Level) > group.MaxLevel) {
continue
}
result = append(result, group)
}
return result
}
@@ -0,0 +1,48 @@
package alignedcmd
import (
"testing"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
func magicBoxJobGroupIDs(groups []MagicBoxRewardGroup) []int64 {
ids := make([]int64, 0, len(groups))
for _, group := range groups {
if len(group.Entries) > 0 {
ids = append(ids, group.Entries[0].ItemID)
}
}
return ids
}
// [booster info]/[charactor] 是按职业(并带等级区间)分组的。
// 只有开启者自己职业那一组才该发 —— 否则一个鬼剑士开豪华婚礼化妆箱,
// 会把全职业的时装/道具一起领走(2026-10-04 真机现场)。
//
// 职业编号见 jobmap:0=鬼剑士[swordman],11=女鬼剑士[at swordman]。
func TestMagicBoxGroupsForCharacterKeepsOnlyTheOpeningJob(t *testing.T) {
groups := []MagicBoxRewardGroup{
{DrawCount: 1, Job: "[swordman]", MinLevel: 1, MaxLevel: 99, Entries: []MagicBoxRewardEntry{{ItemID: 3001, Weight: 100000, Count: 1}}},
{DrawCount: 1, Job: "[at swordman]", MinLevel: 1, MaxLevel: 99, Entries: []MagicBoxRewardEntry{{ItemID: 3011, Weight: 100000, Count: 1}}},
{DrawCount: 2, Entries: []MagicBoxRewardEntry{{ItemID: 3000, Weight: 100000, Count: 2}}},
}
// 鬼剑士:拿 swordman 组 + 无职业门的那组,**不能**拿到女鬼剑士的。
swordman := magicBoxJobGroupIDs(MagicBoxGroupsForCharacter(groups, dnfrepo.CharacterRecord{Job: "0", Level: 90}))
if len(swordman) != 2 || swordman[0] != 3001 || swordman[1] != 3000 {
t.Fatalf("鬼剑士拿到 %v,应为 [3001 3000]", swordman)
}
// 女鬼剑士:只拿 at swordman 组,不能拿到男鬼剑士的。
female := magicBoxJobGroupIDs(MagicBoxGroupsForCharacter(groups, dnfrepo.CharacterRecord{Job: "11", Level: 90}))
if len(female) != 2 || female[0] != 3011 || female[1] != 3000 {
t.Fatalf("女鬼剑士拿到 %v,应为 [3011 3000]", female)
}
// 等级掉出区间:职业组不参与,只剩无职业门的那组。
low := magicBoxJobGroupIDs(MagicBoxGroupsForCharacter(groups, dnfrepo.CharacterRecord{Job: "0", Level: 0}))
if len(low) != 1 || low[0] != 3000 {
t.Fatalf("等级 0 拿到 %v,应只剩 [3000]", low)
}
}
@@ -0,0 +1,62 @@
package alignedcmd
import (
"context"
"fmt"
"longheng.io/server/internal/modules/dnf/dnfenum"
)
// PendingSpec 描述一个已识别但暂不允许回包的命令。
type PendingSpec struct {
Operation string
MinBodyLen int
PendingReason string
}
// PendingHandler 是尚未闭合写包证据的业务域占位 handler。
type PendingHandler struct {
domain dnfenum.AlignedDomain
specs map[dnfenum.CmdPacket]PendingSpec
}
// NewPendingHandler 创建一个只识别请求、不允许成功回包的业务域 handler。
func NewPendingHandler(domain dnfenum.AlignedDomain, specs map[dnfenum.CmdPacket]PendingSpec) PendingHandler {
copied := make(map[dnfenum.CmdPacket]PendingSpec, len(specs))
for op, spec := range specs {
copied[op] = spec
}
return PendingHandler{domain: domain, specs: copied}
}
// Domain 返回业务域。
func (h PendingHandler) Domain() dnfenum.AlignedDomain {
return h.domain
}
// Handle 只做请求识别,禁止 bridge 在证据不足时回成功包。
func (h PendingHandler) Handle(_ context.Context, req Request) (Result, error) {
spec, ok := h.specs[dnfenum.CmdPacket(req.Opcode)]
if !ok {
return Result{
Handled: false,
ResponseAllowed: false,
Operation: "unregistered",
Reason: fmt.Sprintf("%s 模块未登记该 opcode", h.domain),
}, nil
}
if spec.MinBodyLen > 0 && len(req.Body) < spec.MinBodyLen {
return Result{
Handled: true,
ResponseAllowed: false,
Operation: spec.Operation,
Reason: fmt.Sprintf("%s 包体过短:got %d want >= %d", spec.Operation, len(req.Body), spec.MinBodyLen),
}, nil
}
return Result{
Handled: true,
ResponseAllowed: false,
Operation: spec.Operation,
Reason: spec.PendingReason,
}, nil
}
@@ -0,0 +1,46 @@
package alignedcmd
import "longheng.io/server/internal/modules/dnf/dnfenum"
// Action 表示命令进入模块化迁移后的处理决策。
type Action string
const (
ActionPendingModule Action = "pending_module"
ActionBlocked Action = "blocked_by_evidence"
)
// Decision 是 dnfbridge 分流层需要的最小决策结果。
type Decision struct {
Opcode uint16
Domain dnfenum.AlignedDomain
Support dnfenum.AlignedSupport
Action Action
Evidence string
Note string
Reason string
}
// Classify 根据当前 EXE 证据把命令归入模块化队列或阻断旧映射。
func Classify(opcode uint16) (Decision, bool) {
if aligned, ok := dnfenum.LookupAlignedCommand(opcode); ok {
return Decision{
Opcode: opcode,
Domain: aligned.Domain,
Support: aligned.Support,
Action: ActionPendingModule,
Evidence: aligned.Evidence,
Note: aligned.Note,
}, true
}
if blocked, ok := dnfenum.LookupBlockedMigration(opcode); ok {
return Decision{
Opcode: opcode,
Domain: blocked.Domain,
Action: ActionBlocked,
Evidence: blocked.Evidence,
Reason: blocked.Reason,
}, true
}
return Decision{}, false
}
@@ -0,0 +1,122 @@
package alignedcmd
import (
"context"
"testing"
"longheng.io/server/internal/modules/dnf/dnfenum"
)
func TestClassifyPendingModule(t *testing.T) {
got, ok := Classify(uint16(dnfenum.CmdPacketDeleteItem))
if !ok {
t.Fatalf("Classify(DeleteItem) not found")
}
if got.Action != ActionPendingModule {
t.Fatalf("action = %q, want %q", got.Action, ActionPendingModule)
}
if got.Domain != dnfenum.AlignedDomainInventory {
t.Fatalf("domain = %q, want %q", got.Domain, dnfenum.AlignedDomainInventory)
}
if got.Support != dnfenum.AlignedSupportDirect {
t.Fatalf("support = %q, want %q", got.Support, dnfenum.AlignedSupportDirect)
}
}
func TestClassifyBlocked(t *testing.T) {
got, ok := Classify(uint16(dnfenum.CmdPacketGetItembox))
if !ok {
t.Fatalf("Classify(519) not found")
}
if got.Action != ActionBlocked {
t.Fatalf("action = %q, want %q", got.Action, ActionBlocked)
}
if got.Domain != dnfenum.AlignedDomainPackage {
t.Fatalf("domain = %q, want %q", got.Domain, dnfenum.AlignedDomainPackage)
}
}
func TestRegistryRoutePendingWithoutHandler(t *testing.T) {
registry := DefaultRegistry()
got, ok, err := registry.Route(context.Background(), Request{Opcode: uint16(dnfenum.CmdPacketMailboxOpen), Body: []byte{1, 2, 3}})
if err != nil {
t.Fatalf("Route error = %v", err)
}
if !ok {
t.Fatalf("Route(MailboxOpen) not classified")
}
if !got.Handled {
t.Fatalf("pending command should be handled by router")
}
if got.ResponseAllowed {
t.Fatalf("pending command without handler must not allow response")
}
if got.Decision.Domain != dnfenum.AlignedDomainMail {
t.Fatalf("domain = %q, want %q", got.Decision.Domain, dnfenum.AlignedDomainMail)
}
}
func TestRegistryRouteBlocked(t *testing.T) {
registry := DefaultRegistry()
got, ok, err := registry.Route(context.Background(), Request{Opcode: uint16(dnfenum.CmdPacketGetItembox)})
if err != nil {
t.Fatalf("Route error = %v", err)
}
if !ok {
t.Fatalf("Route(519) not classified")
}
if got.ResponseAllowed {
t.Fatalf("blocked command must not allow response")
}
if got.Decision.Action != ActionBlocked {
t.Fatalf("action = %q, want %q", got.Decision.Action, ActionBlocked)
}
}
func TestRegistryRouteRequiresCurrentCommandDispatcher(t *testing.T) {
registry := DefaultRegistry()
_, ok, err := registry.Route(context.Background(), Request{
Command: byte(dnfenum.GameCmdNotice),
CommandKnown: true,
Opcode: uint16(dnfenum.CmdPacketBuySkill),
})
if err != nil {
t.Fatalf("Route error = %v", err)
}
if ok {
t.Fatal("notice dispatcher type 29 was misrouted as a skill command")
}
}
type postActionCloneHandler struct {
actions []PostAction
}
func (postActionCloneHandler) Domain() dnfenum.AlignedDomain {
return dnfenum.AlignedDomainMail
}
func (h postActionCloneHandler) Handle(context.Context, Request) (Result, error) {
return Result{
Handled: true,
ResponseAllowed: true,
Operation: "post_action_clone",
PostActions: h.actions,
}, nil
}
func TestRegistryRouteClonesPostActions(t *testing.T) {
actions := []PostAction{PostActionRefreshSelectedActorAppearance}
registry := NewRegistry(postActionCloneHandler{actions: actions})
got, ok, err := registry.Route(context.Background(), Request{Opcode: uint16(dnfenum.CmdPacketMailboxOpen)})
if err != nil {
t.Fatalf("Route error = %v", err)
}
if !ok || len(got.PostActions) != 1 || got.PostActions[0] != PostActionRefreshSelectedActorAppearance {
t.Fatalf("post actions = %#v", got.PostActions)
}
actions[0] = "mutated_after_route"
if got.PostActions[0] != PostActionRefreshSelectedActorAppearance {
t.Fatalf("route result retained handler slice alias: %#v", got.PostActions)
}
}
@@ -0,0 +1,38 @@
package alignedcmd
// 抽奖类消耗品(PVF `[stackable type]` = `[monster tarot card]`)的使用契约。
//
// 与 MagicBoxResolution 同族,但有两处必须分开表达的东西:
// - **金币花费**:PVF `[lottery use cost]` 扣的是**金币**(服主 2026-09-29 确认:
// 塔罗牌这次就是 100000)。它的提交必须和道具同笔,所以交给
// Owner.withinCharacterAssetTransaction(金币 + 道具 + 装备三个仓库同一事务),
// 不能走只有背包的 withinInventoryTransaction。
// - **每项数量**:`[int data]` 的三元组是 (item, weight, count),count 可能不是 1
// (真档 10159697 的最后一行是 `10159142 300000 250`),所以中奖项自带 Count。
//
// 抽取在**服务端解析阶段**完成(用服务自身的可注入选择函数,测试可注入确定值),
// 这里只携带**已经抽中的那一项**:Owner 侧因此是纯确定性的,不含随机源。
type TarotLotteryOutcome struct {
Reward MagicBoxRewardItem
Count int64
}
// TarotLotteryResolution is the active-PVF contract for one tarot-card use.
type TarotLotteryResolution struct {
SourceItemID int64
SourcePVFPath string
StackableType string
// GoldCost is PVF `[lottery use cost]` in gold. It is read from the document,
// never hard-coded per item.
GoldCost int64
// Drawn is the winning entry, or nil when the roll landed in the table's gap.
Drawn *TarotLotteryOutcome
// Missed marks a roll that landed in `[total_weight, scale)` -- a gap the
// shipped tables can have (10159697 sums to 999998 out of 1000000). This tree
// treats it as a failure and aborts the whole use (see useTarotLotteryCard):
// 宁可这次不生效,也不能让玩家花了钱拿到空气。这是我们与官服可能不同的一处
// 保守选择 —— 官方"照收钱不给东西"或"整次中止"两种做法都说得通。
Missed bool
Roll int64
TotalWeight int64
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,547 @@
package auction
import (
"context"
"errors"
"testing"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
// seedGoldConsignCharacter writes a character with the level the consignment
// gates look at, plus a gold wallet.
func seedGoldConsignCharacter(t *testing.T, ctx context.Context, repositories dnfrepo.Group, characterID, accountID, name string, level int, gold int64) {
t.Helper()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: characterID, AccountID: accountID, Name: name, Level: level,
Stats: map[string]int64{"gold": gold},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: characterID, Slots: map[string]dnfrepo.ItemStack{},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: accountID, Metadata: map[string]string{},
}); err != nil {
t.Fatal(err)
}
}
func goldConsignCharGold(t *testing.T, ctx context.Context, repositories dnfrepo.Group, characterID string) int64 {
t.Helper()
character, found, err := repositories.Character.Load(ctx, characterID)
if err != nil || !found {
t.Fatalf("load character %s: found=%t err=%v", characterID, found, err)
}
return character.Stats["gold"]
}
func goldConsignEscrowGold(t *testing.T, ctx context.Context, repositories dnfrepo.Group) int64 {
t.Helper()
character, found, err := repositories.Character.Load(ctx, EscrowCharacterID)
if err != nil || !found {
t.Fatalf("load escrow: found=%t err=%v", found, err)
}
return character.Stats[GoldConsignEscrowGoldKey]
}
func mustGoldConsignOwner(t *testing.T, repositories dnfrepo.Group) *GoldConsignOwner {
t.Helper()
owner, err := NewGoldConsignOwner(repositories)
if err != nil {
t.Fatal(err)
}
return owner
}
// The table is the client's own, read off NoPack.exe .rdata VA 0x4580AA8. The
// ids are deliberately NOT contiguous, so this test exists to stop anybody
// "simplifying" it into a base+index formula that would misprice five tiers.
func TestGoldConsignTierTableMatchesTheClientImage(t *testing.T) {
expected := []GoldConsignTier{
{2681725, 1_000_000}, {2681726, 2_000_000}, {2681727, 3_000_000},
{2681728, 4_000_000}, {2681729, 5_000_000}, {2681730, 6_000_000},
{2681731, 7_000_000}, {2681732, 8_000_000}, {2681733, 9_000_000},
{2681734, 10_000_000}, {2681735, 20_000_000}, {2681736, 30_000_000},
{2683069, 40_000_000}, {2683070, 50_000_000}, {2683071, 100_000_000},
{2683072, 150_000_000}, {2683073, 200_000_000},
}
got := GoldConsignTiers()
if len(got) != 17 || len(got) != len(expected) {
t.Fatalf("tier count = %d, want 17", len(got))
}
for index := range expected {
if got[index] != expected[index] {
t.Fatalf("tier %d = %+v, want %+v", index, got[index], expected[index])
}
}
// The gap is the whole point: 2681737 is NOT a tier, and neither is
// 2681741, which a contiguous formula would happily resolve.
for _, absent := range []int64{2681724, 2681737, 2681741, 2683068, 2683074, 0, -1} {
if IsGoldConsignTier(absent) {
t.Fatalf("item %d must not be a gold tier", absent)
}
}
if amount, ok := GoldConsignTierAmount(2683073); !ok || amount != 200_000_000 {
t.Fatalf("top tier = %d ok=%t, want 200000000", amount, ok)
}
}
// The official commission table, including its boundaries: the first row reads
// 「40% 以下」, so exactly 40% is still listable.
func TestResolveGoldConsignFeeFollowsTheOfficialTable(t *testing.T) {
const average = 1000
for _, test := range []struct {
name string
price int64
coupon bool
wantFee int64
wantListed bool
}{
{"below 40% is refused", 399, false, 0, false},
{"below 40% is refused with a coupon too", 399, true, 0, false},
{"exactly 40% lists", 400, false, 5, true},
{"exactly 40% lists at the coupon rate", 400, true, 1, true},
{"mid band", 900, false, 5, true},
{"exactly 150% is still the base band", 1500, false, 5, true},
{"exactly 150% takes the coupon", 1500, true, 1, true},
{"just over 150% jumps to 30%", 1501, false, 30, true},
{"the coupon cannot help above 150%", 1501, true, 30, true},
{"exactly 160%", 1600, false, 30, true},
{"just over 160%", 1601, false, 40, true},
{"exactly 170%", 1700, false, 40, true},
{"just over 170%", 1701, false, 50, true},
{"exactly 180%", 1800, false, 50, true},
{"just over 180%", 1801, false, 95, true},
{"the coupon cannot help in the 95% band either", 9999, true, 95, true},
} {
t.Run(test.name, func(t *testing.T) {
fee, listable := ResolveGoldConsignFee(test.price, average, test.coupon)
if listable != test.wantListed || fee != test.wantFee {
t.Fatalf("fee=%d listable=%t, want fee=%d listable=%t",
fee, listable, test.wantFee, test.wantListed)
}
})
}
}
// With no sales the average is 0 and the table has no meaning. The operator's
// decision is to keep selling at the ordinary band rather than divide by zero
// or dump every listing into the 95% band.
func TestGoldConsignNoSalesFallsBackToTheBaseFee(t *testing.T) {
if fee, listable := ResolveGoldConsignFee(1, 0, false); !listable || fee != GoldConsignBaseFeePercent {
t.Fatalf("no-history fee=%d listable=%t, want %d/true", fee, listable, GoldConsignBaseFeePercent)
}
if fee, listable := ResolveGoldConsignFee(999_999, 0, true); !listable || fee != GoldConsignCouponFeePercent {
t.Fatalf("no-history coupon fee=%d listable=%t, want %d/true", fee, listable, GoldConsignCouponFeePercent)
}
// A non-positive price is never listable, average or not.
if _, listable := ResolveGoldConsignFee(0, 0, false); listable {
t.Fatal("a zero price must not be listable")
}
}
func TestGoldConsignProceedsApplyTheFrozenCommission(t *testing.T) {
for _, test := range []struct{ price, fee, want int64 }{
{111, 5, 105},
{111, 1, 109},
{100, 30, 70},
{100, 95, 5},
{100, 100, 0},
{0, 5, 0},
} {
if got := GoldConsignProceeds(test.price, test.fee); got != test.want {
t.Fatalf("proceeds(%d,%d) = %d, want %d", test.price, test.fee, got, test.want)
}
}
}
// Register escrows the consigned gold itself - no extra deposit - and writes
// one row whose frozen fee is what the seller was told at registration.
func TestGoldConsignRegisterEscrowsGoldAndFreezesTheFee(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
seedGoldConsignCharacter(t, ctx, repositories, "seller", "account-seller", "卖家", 40, 50_000_000)
owner := mustGoldConsignOwner(t, repositories)
result, err := owner.Register(ctx, GoldConsignRegisterCommand{
ListingID: 1, SellerID: "seller", AccountID: "account-seller",
GoldItemID: 2681725, PriceCera: 111, DurationHour: 24,
})
if err != nil {
t.Fatal(err)
}
if result.Listing.GoldAmount != 1_000_000 {
t.Fatalf("escrowed amount = %d, want 1000000", result.Listing.GoldAmount)
}
if result.Listing.SellerName != "卖家" {
t.Fatalf("seller name = %q, want the character row's own", result.Listing.SellerName)
}
// No sales yet, so the ordinary band applies and the coupon is irrelevant.
if result.Listing.FeePercent != GoldConsignBaseFeePercent {
t.Fatalf("frozen fee = %d, want %d", result.Listing.FeePercent, GoldConsignBaseFeePercent)
}
if result.Listing.ProceedsCera != 105 {
t.Fatalf("proceeds = %d, want 105", result.Listing.ProceedsCera)
}
if gold := goldConsignCharGold(t, ctx, repositories, "seller"); gold != 49_000_000 {
t.Fatalf("seller gold = %d, want 49000000", gold)
}
if gold := goldConsignEscrowGold(t, ctx, repositories); gold != 1_000_000 {
t.Fatalf("escrow gold = %d, want 1000000", gold)
}
if result.Listing.ExpiresAt.Sub(result.Listing.CreatedAt) != 24*time.Hour {
t.Fatalf("lifetime = %v, want 24h", result.Listing.ExpiresAt.Sub(result.Listing.CreatedAt))
}
// The gold is conserved: seller + escrow is what the seller started with.
if goldConsignCharGold(t, ctx, repositories, "seller")+goldConsignEscrowGold(t, ctx, repositories) != 50_000_000 {
t.Fatal("gold was created or destroyed by the escrow move")
}
}
func TestGoldConsignRegisterRefusesWhatItCannotHonour(t *testing.T) {
ctx := context.Background()
cases := []struct {
name string
cmd GoldConsignRegisterCommand
gold int64
level int
want error
}{
{
name: "seller below level 30",
cmd: GoldConsignRegisterCommand{ListingID: 1, SellerID: "seller", AccountID: "account-seller", GoldItemID: 2681725, PriceCera: 111, DurationHour: 24},
gold: 50_000_000, level: 29, want: ErrGoldConsignSellerLevel,
},
{
name: "item is not a denomination",
cmd: GoldConsignRegisterCommand{ListingID: 1, SellerID: "seller", AccountID: "account-seller", GoldItemID: 2681741, PriceCera: 111, DurationHour: 24},
gold: 50_000_000, level: 40, want: ErrGoldConsignNotTier,
},
{
name: "not enough gold to consign",
cmd: GoldConsignRegisterCommand{ListingID: 1, SellerID: "seller", AccountID: "account-seller", GoldItemID: 2681736, PriceCera: 111, DurationHour: 24},
gold: 29_999_999, level: 40, want: ErrGoldUnavailable,
},
{
name: "lifetime outside 24/48",
cmd: GoldConsignRegisterCommand{ListingID: 1, SellerID: "seller", AccountID: "account-seller", GoldItemID: 2681725, PriceCera: 111, DurationHour: 12},
gold: 50_000_000, level: 40, want: ErrInvalidRequest,
},
{
name: "non-positive price",
cmd: GoldConsignRegisterCommand{ListingID: 1, SellerID: "seller", AccountID: "account-seller", GoldItemID: 2681725, PriceCera: 0, DurationHour: 24},
gold: 50_000_000, level: 40, want: ErrInvalidRequest,
},
{
name: "wrong account",
cmd: GoldConsignRegisterCommand{ListingID: 1, SellerID: "seller", AccountID: "someone-else", GoldItemID: 2681725, PriceCera: 111, DurationHour: 24},
gold: 50_000_000, level: 40, want: ErrInvalidRequest,
},
}
for _, test := range cases {
t.Run(test.name, func(t *testing.T) {
repositories := dnfrepomemory.NewMemoryGroup()
seedGoldConsignCharacter(t, ctx, repositories, "seller", "account-seller", "Seller", test.level, test.gold)
owner := mustGoldConsignOwner(t, repositories)
if _, err := owner.Register(ctx, test.cmd); !errors.Is(err, test.want) {
t.Fatalf("err = %v, want %v", err, test.want)
}
// A refusal must not move any gold.
if gold := goldConsignCharGold(t, ctx, repositories, "seller"); gold != test.gold {
t.Fatalf("refused register moved gold: %d, want %d", gold, test.gold)
}
})
}
}
func TestGoldConsignCancelReturnsTheGoldAndDropsTheRow(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
seedGoldConsignCharacter(t, ctx, repositories, "seller", "account-seller", "Seller", 40, 50_000_000)
owner := mustGoldConsignOwner(t, repositories)
if _, err := owner.Register(ctx, GoldConsignRegisterCommand{
ListingID: 7, SellerID: "seller", AccountID: "account-seller",
GoldItemID: 2681735, PriceCera: 900, DurationHour: 48,
}); err != nil {
t.Fatal(err)
}
if gold := goldConsignEscrowGold(t, ctx, repositories); gold != 20_000_000 {
t.Fatalf("escrow before cancel = %d, want 20000000", gold)
}
result, err := owner.Cancel(ctx, "seller", "account-seller", 7)
if err != nil {
t.Fatal(err)
}
if result.RefundGold != 20_000_000 {
t.Fatalf("refund = %d, want 20000000", result.RefundGold)
}
if gold := goldConsignEscrowGold(t, ctx, repositories); gold != 0 {
t.Fatalf("escrow after cancel = %d, want 0", gold)
}
// The refund goes home by letter, so the seller's wallet is untouched here
// and the gold sits in no wallet at all until the letter is claimed.
if gold := goldConsignCharGold(t, ctx, repositories, "seller"); gold != 30_000_000 {
t.Fatalf("seller gold after cancel = %d, want 30000000 (still unredeemed)", gold)
}
if _, err := owner.LoadListing(ctx, 7); !errors.Is(err, ErrListingMissing) {
t.Fatalf("cancelled row still loads: %v", err)
}
// Cancelling twice is a missing row, not a second refund.
if _, err := owner.Cancel(ctx, "seller", "account-seller", 7); !errors.Is(err, ErrListingMissing) {
t.Fatalf("second cancel err = %v, want ErrListingMissing", err)
}
// Somebody else's cancel is refused.
if _, err := owner.Register(ctx, GoldConsignRegisterCommand{
ListingID: 8, SellerID: "seller", AccountID: "account-seller",
GoldItemID: 2681725, PriceCera: 111, DurationHour: 24,
}); err != nil {
t.Fatal(err)
}
if _, err := owner.Cancel(ctx, "someone-else", "someone-else", 8); !errors.Is(err, ErrNotOwner) {
t.Fatalf("foreign cancel err = %v, want ErrNotOwner", err)
}
}
// The buy saga: reserve, settle, and the sold row that becomes both the
// delivery intent and the fee history.
func TestGoldConsignBuySagaSettlesAndFeedsTheAverage(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
seedGoldConsignCharacter(t, ctx, repositories, "seller", "account-seller", "Seller", 40, 10_000_000)
seedGoldConsignCharacter(t, ctx, repositories, "buyer", "account-buyer", "Buyer", 20, 0)
owner := mustGoldConsignOwner(t, repositories)
if _, err := owner.Register(ctx, GoldConsignRegisterCommand{
ListingID: 3, SellerID: "seller", AccountID: "account-seller",
GoldItemID: 2681725, PriceCera: 111, DurationHour: 24,
}); err != nil {
t.Fatal(err)
}
if average, samples, err := owner.AverageCeraPrice(ctx, 2681725); err != nil || average != 0 || samples != 0 {
t.Fatalf("average before any sale = %d/%d err=%v, want 0/0", average, samples, err)
}
reservation, err := owner.Reserve(ctx, "buyer", "account-buyer", 3)
if err != nil {
t.Fatal(err)
}
if reservation.Listing.Status != GoldConsignReserved || reservation.Listing.BuyerID != "buyer" {
t.Fatalf("reservation = %+v", reservation.Listing)
}
// The row is out of the shop while reserved, so nobody else can take it.
live, err := owner.Search(ctx, nil)
if err != nil {
t.Fatal(err)
}
if len(live) != 0 {
t.Fatalf("a reserved row must not stay on sale: %+v", live)
}
if _, err := owner.Reserve(ctx, "buyer", "account-buyer", 3); !errors.Is(err, ErrGoldConsignNotActive) {
t.Fatalf("double reserve err = %v, want ErrGoldConsignNotActive", err)
}
sold, err := owner.Settle(ctx, "buyer", 3)
if err != nil {
t.Fatal(err)
}
if sold.Status != GoldConsignSold || sold.ProceedsCera != 105 {
t.Fatalf("settled = %+v, want sold with 105 proceeds", sold)
}
// The gold has left the escrow wallet for the buyer's letter.
if gold := goldConsignEscrowGold(t, ctx, repositories); gold != 0 {
t.Fatalf("escrow after settle = %d, want 0", gold)
}
// And it is now the fee history the next registration reads.
average, samples, err := owner.AverageCeraPrice(ctx, 2681725)
if err != nil || average != 111 || samples != 1 {
t.Fatalf("average after one sale = %d/%d err=%v, want 111/1", average, samples, err)
}
pending, err := owner.UndeliveredSales(ctx)
if err != nil {
t.Fatal(err)
}
if len(pending) != 1 || pending[0].ID != 3 || pending[0].Delivered {
t.Fatalf("undelivered sales = %+v", pending)
}
// 第一次盖章才算跃迁(freshlyDelivered=true)——记录侧全靠这一位保证只写一条。
freshlyDelivered, err := owner.MarkDelivered(ctx, "buyer", 3)
if err != nil {
t.Fatal(err)
}
if !freshlyDelivered {
t.Fatal("首次 MarkDelivered 必须报告 freshlyDelivered=true")
}
// 重放同一次盖章:行已经盖过,必须报告 false(否则记录会多出一条成交收款)。
if again, err := owner.MarkDelivered(ctx, "buyer", 3); err != nil || again {
t.Fatalf("重放 MarkDelivered = %t/%v, want false/nil", again, err)
}
pending, err = owner.UndeliveredSales(ctx)
if err != nil {
t.Fatal(err)
}
if len(pending) != 0 {
t.Fatalf("stamped sale still pending: %+v", pending)
}
// Releasing a settled row must not resurrect it.
if err := owner.Release(ctx, "buyer", 3); err != nil {
t.Fatal(err)
}
if _, err := owner.Settle(ctx, "buyer", 3); !errors.Is(err, ErrListingMissing) {
t.Fatalf("re-settle err = %v, want ErrListingMissing", err)
}
}
// Once a sale exists the bands switch on by themselves, which is the whole
// point of starting the average at zero.
func TestGoldConsignBandsActivateOnceSalesExist(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
seedGoldConsignCharacter(t, ctx, repositories, "seller", "account-seller", "Seller", 40, 100_000_000)
seedGoldConsignCharacter(t, ctx, repositories, "buyer", "account-buyer", "Buyer", 20, 0)
owner := mustGoldConsignOwner(t, repositories)
// Seed the history directly: one 1百万 sale at 1000 Cera.
box := GoldConsignListing{
ID: 900, SellerID: "seller", GoldItemID: 2681725, GoldAmount: 1_000_000,
PriceCera: 1000, FeePercent: 5, ProceedsCera: 950, Status: GoldConsignSold,
CreatedAt: time.Now().UTC(), ExpiresAt: time.Now().UTC().Add(time.Hour),
}
if err := owner.EnsureEscrow(ctx); err != nil {
t.Fatal(err)
}
stored, found, err := repositories.Inventory.Load(ctx, EscrowCharacterID)
if err != nil || !found {
t.Fatal(err)
}
if stored.Slots == nil {
stored.Slots = map[string]dnfrepo.ItemStack{}
}
stack := goldConsignRowStack(box)
stack.Extra["sold_at"] = "1"
stored.Slots[goldConsignSoldKey(box.ID)] = stack
if err := repositories.Inventory.Save(ctx, stored); err != nil {
t.Fatal(err)
}
if average, samples, err := owner.AverageCeraPrice(ctx, 2681725); err != nil || average != 1000 || samples != 1 {
t.Fatalf("seeded average = %d/%d err=%v, want 1000/1", average, samples, err)
}
// 300 is below 40% of 1000, so the table refuses the listing outright.
if _, err := owner.Register(ctx, GoldConsignRegisterCommand{
ListingID: 11, SellerID: "seller", AccountID: "account-seller",
GoldItemID: 2681725, PriceCera: 300, DurationHour: 24,
}); !errors.Is(err, ErrGoldConsignPriceRejected) {
t.Fatalf("below-band register err = %v, want ErrGoldConsignPriceRejected", err)
}
// 400 is exactly 40% and lists at the base rate.
result, err := owner.Register(ctx, GoldConsignRegisterCommand{
ListingID: 12, SellerID: "seller", AccountID: "account-seller",
GoldItemID: 2681725, PriceCera: 400, DurationHour: 24,
})
if err != nil {
t.Fatal(err)
}
if result.Listing.FeePercent != GoldConsignBaseFeePercent {
t.Fatalf("40%% band fee = %d, want %d", result.Listing.FeePercent, GoldConsignBaseFeePercent)
}
// 2000 is 200% of the average, which is the 95% band.
steep, err := owner.Register(ctx, GoldConsignRegisterCommand{
ListingID: 13, SellerID: "seller", AccountID: "account-seller",
GoldItemID: 2681725, PriceCera: 2000, DurationHour: 24,
})
if err != nil {
t.Fatal(err)
}
if steep.Listing.FeePercent != 95 {
t.Fatalf("200%% band fee = %d, want 95", steep.Listing.FeePercent)
}
}
func TestGoldConsignExpiryReturnsTheGoldAndClearsTheRow(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
seedGoldConsignCharacter(t, ctx, repositories, "seller", "account-seller", "Seller", 40, 5_000_000)
owner := mustGoldConsignOwner(t, repositories)
// A past expiry is produced by moving the ledger's clock back far enough
// that Register's own 24h lifetime has already run out: 30h ago plus 24h
// leaves the row six hours stale.
owner.now = func() time.Time { return time.Now().UTC().Add(-30 * time.Hour) }
if _, err := owner.Register(ctx, GoldConsignRegisterCommand{
ListingID: 21, SellerID: "seller", AccountID: "account-seller",
GoldItemID: 2681725, PriceCera: 111, DurationHour: 24,
}); err != nil {
t.Fatal(err)
}
owner.now = time.Now
expired, err := owner.ReturnExpired(ctx, "seller", "account-seller")
if err != nil {
t.Fatal(err)
}
if len(expired) != 1 || expired[0].Refund != 1_000_000 {
t.Fatalf("expired = %+v", expired)
}
if gold := goldConsignEscrowGold(t, ctx, repositories); gold != 0 {
t.Fatalf("escrow after expiry = %d, want 0", gold)
}
again, err := owner.ReturnExpired(ctx, "seller", "account-seller")
if err != nil {
t.Fatal(err)
}
if len(again) != 0 {
t.Fatalf("a second sweep re-returned %+v", again)
}
}
// A buyer may not take their own consignment, and a buyer below level 20 is
// refused before any money moves.
func TestGoldConsignBuyRefusesSelfDealingAndLowLevel(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
seedGoldConsignCharacter(t, ctx, repositories, "seller", "account-seller", "Seller", 40, 5_000_000)
seedGoldConsignCharacter(t, ctx, repositories, "lowbie", "account-lowbie", "Lowbie", 19, 0)
owner := mustGoldConsignOwner(t, repositories)
if _, err := owner.Register(ctx, GoldConsignRegisterCommand{
ListingID: 31, SellerID: "seller", AccountID: "account-seller",
GoldItemID: 2681725, PriceCera: 111, DurationHour: 24,
}); err != nil {
t.Fatal(err)
}
if _, err := owner.Reserve(ctx, "seller", "account-seller", 31); !errors.Is(err, ErrGoldConsignBuyerIsSeller) {
t.Fatalf("self-buy err = %v, want ErrGoldConsignBuyerIsSeller", err)
}
if _, err := owner.Reserve(ctx, "lowbie", "account-lowbie", 31); !errors.Is(err, ErrGoldConsignBuyerLevel) {
t.Fatalf("low-level buy err = %v, want ErrGoldConsignBuyerLevel", err)
}
// Neither refusal may have taken the row off sale.
live, err := owner.Search(ctx, nil)
if err != nil {
t.Fatal(err)
}
if len(live) != 1 {
t.Fatalf("refused reserves changed the listing set: %+v", live)
}
}
func TestGoldConsignActiveListingLimit(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
seedGoldConsignCharacter(t, ctx, repositories, "seller", "account-seller", "Seller", 40, 50_000_000)
owner := mustGoldConsignOwner(t, repositories)
for index := 0; index < GoldConsignMaxActiveListings; index++ {
if _, err := owner.Register(ctx, GoldConsignRegisterCommand{
ListingID: uint64(100 + index), SellerID: "seller", AccountID: "account-seller",
GoldItemID: 2681725, PriceCera: 111, DurationHour: 24,
}); err != nil {
t.Fatalf("listing %d: %v", index, err)
}
}
if _, err := owner.Register(ctx, GoldConsignRegisterCommand{
ListingID: 200, SellerID: "seller", AccountID: "account-seller",
GoldItemID: 2681725, PriceCera: 111, DurationHour: 24,
}); !errors.Is(err, ErrGoldConsignLimit) {
t.Fatalf("over-limit register err = %v, want ErrGoldConsignLimit", err)
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,184 @@
package auraskin
import (
"context"
"errors"
"fmt"
"math"
"path"
"strconv"
"strings"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const (
FlagStat = "aura_flag"
TicketPVFPath = "stackable/cash/chn_490700411.stk"
ItemStackable = "stackable"
)
var (
ErrOwnerUnavailable = errors.New("aura skin owner unavailable")
ErrCharacterRequired = errors.New("selected character id required")
ErrCharacterNotFound = errors.New("character record not found")
ErrAccountMismatch = errors.New("character account does not match session account")
ErrInventoryNotFound = errors.New("inventory record not found")
ErrSourceMissing = errors.New("aura skin source item missing")
ErrTicketInvalid = errors.New("aura skin source is not the PVF expansion ticket")
)
type ItemDefinition struct {
Kind string
PVFPath string
}
type ItemCatalog interface {
ResolveItem(uint32) (ItemDefinition, error)
}
type Command struct {
AccountID string
SelectedCharacterID uint16
SourceSlot int16
UpdatedAt time.Time
}
type Result struct {
CharacterID string
SourceSlot int16
SourceItemID int64
SourcePVFPath string
Consumed bool
AlreadyOpen bool
SourceChanged bool
SourceRemoved bool
RemainingStack dnfrepo.ItemStack
}
// Owner owns the aura-slot rule and the atomic character/inventory mutation.
// It deliberately has no dependency on packet writers or current-client rows.
type Owner struct {
assets dnfrepo.CharacterAssetUnitOfWork
catalog ItemCatalog
}
func NewOwner(repos dnfrepo.Group, catalog ItemCatalog) (*Owner, error) {
if repos.CharacterAssets == nil || catalog == nil {
return nil, ErrOwnerUnavailable
}
return &Owner{assets: repos.CharacterAssets, catalog: catalog}, nil
}
func (o *Owner) Open(ctx context.Context, cmd Command) (Result, error) {
if o == nil || o.assets == nil || o.catalog == nil {
return Result{}, ErrOwnerUnavailable
}
if cmd.SelectedCharacterID == 0 {
return Result{}, ErrCharacterRequired
}
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
accountID := strings.TrimSpace(cmd.AccountID)
result := Result{CharacterID: characterID, SourceSlot: cmd.SourceSlot}
err := o.assets.WithinCharacterAssets(ctx, characterID, func(
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.CharacterID) != characterID {
return fmt.Errorf("%w: character=%s", ErrCharacterNotFound, characterID)
}
if accountID != "" && strings.TrimSpace(character.AccountID) != accountID {
return fmt.Errorf("%w: character=%s", ErrAccountMismatch, characterID)
}
character = dnfrepo.CloneCharacter(character)
if character.Stats == nil {
character.Stats = make(map[string]int64, 1)
}
if character.Stats[FlagStat] != 0 {
result.AlreadyOpen = true
return nil
}
inventory, found, err := inventories.Load(ctx, characterID)
if err != nil {
return err
}
if !found || strings.TrimSpace(inventory.CharacterID) != characterID {
return fmt.Errorf("%w: character=%s", ErrInventoryNotFound, characterID)
}
inventory = dnfrepo.CloneInventory(inventory)
sourceKey := inventoryKey(cmd.SourceSlot)
sourceStack, found := inventory.Slots[sourceKey]
if !found || sourceStack.ItemID <= 0 || sourceStack.ItemID > math.MaxUint32 || sourceStack.Count <= 0 {
return fmt.Errorf("%w: slot=%d", ErrSourceMissing, cmd.SourceSlot)
}
definition, err := o.resolveTicket(uint32(sourceStack.ItemID))
if err != nil {
return err
}
result.SourceItemID = sourceStack.ItemID
result.SourcePVFPath = definition.PVFPath
sourceStack.Count--
if sourceStack.Count == 0 {
delete(inventory.Slots, sourceKey)
result.SourceRemoved = true
} else {
inventory.Slots[sourceKey] = sourceStack
result.RemainingStack = sourceStack
}
result.SourceChanged = true
result.Consumed = true
now := cmd.UpdatedAt
if now.IsZero() {
now = time.Now().UTC()
} else {
now = now.UTC()
}
character.Stats[FlagStat] = 1
character.UpdatedAt = now
if err := dnfrepo.SaveCharacterFields(ctx, characters, character, dnfrepo.CharacterFieldStats); err != nil {
return err
}
inventory.UpdatedAt = now
return dnfrepo.SaveInventoryFields(ctx, inventories, inventory, dnfrepo.InventoryFieldSlots)
})
if err != nil {
return Result{}, err
}
return result, nil
}
func (o *Owner) resolveTicket(itemID uint32) (ItemDefinition, error) {
definition, err := o.catalog.ResolveItem(itemID)
if err != nil {
return ItemDefinition{}, fmt.Errorf("%w: item_id=%d resolve=%v", ErrTicketInvalid, itemID, err)
}
if strings.ToLower(strings.TrimSpace(definition.Kind)) != ItemStackable ||
cleanPVFPath(definition.PVFPath) != TicketPVFPath {
return ItemDefinition{}, fmt.Errorf(
"%w: item_id=%d kind=%s path=%s",
ErrTicketInvalid,
itemID,
definition.Kind,
definition.PVFPath,
)
}
return definition, nil
}
func inventoryKey(slot int16) string {
return fmt.Sprintf("%d:%d", dnfrepo.MainInventoryListType, slot)
}
func cleanPVFPath(value string) string {
value = strings.ReplaceAll(strings.TrimSpace(value), "\\", "/")
return strings.ToLower(strings.TrimPrefix(path.Clean("/"+value), "/"))
}
@@ -0,0 +1,147 @@
package auraskin
import (
"context"
"errors"
"testing"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
type catalogStub map[uint32]ItemDefinition
func (c catalogStub) ResolveItem(itemID uint32) (ItemDefinition, error) {
definition, ok := c[itemID]
if !ok {
return ItemDefinition{}, errors.New("missing item")
}
return definition, nil
}
func TestOwnerOpenConsumesTicketAndPersistsFlag(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "19",
AccountID: "account-1",
Stats: map[string]int64{FlagStat: 0},
}); err != nil {
t.Fatalf("save character: %v", err)
}
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{
"0:122": {ItemID: 490700411, Count: 2},
},
}); err != nil {
t.Fatalf("save inventory: %v", err)
}
owner, err := NewOwner(repos, catalogStub{
490700411: {Kind: ItemStackable, PVFPath: `stackable\cash\chn_490700411.stk`},
})
if err != nil {
t.Fatalf("NewOwner: %v", err)
}
result, err := owner.Open(ctx, Command{
AccountID: "account-1",
SelectedCharacterID: 19,
SourceSlot: 122,
})
if err != nil {
t.Fatalf("Open: %v", err)
}
if !result.Consumed || !result.SourceChanged || result.SourceRemoved ||
result.RemainingStack.Count != 1 {
t.Fatalf("result = %+v", result)
}
character, _, err := repos.Character.Load(ctx, "19")
if err != nil {
t.Fatalf("load character: %v", err)
}
if character.Stats[FlagStat] != 1 {
t.Fatalf("aura flag = %d", character.Stats[FlagStat])
}
inventory, _, err := repos.Inventory.Load(ctx, "19")
if err != nil {
t.Fatalf("load inventory: %v", err)
}
if inventory.Slots["0:122"].Count != 1 {
t.Fatalf("source stack = %+v", inventory.Slots["0:122"])
}
}
func TestOwnerOpenAlreadyOpenDoesNotResolveOrConsume(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "19",
AccountID: "account-1",
Stats: map[string]int64{FlagStat: 1},
}); err != nil {
t.Fatalf("save character: %v", err)
}
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{
"0:122": {ItemID: 490700411, Count: 1},
},
}); err != nil {
t.Fatalf("save inventory: %v", err)
}
owner, err := NewOwner(repos, catalogStub{})
if err != nil {
t.Fatalf("NewOwner: %v", err)
}
result, err := owner.Open(ctx, Command{
AccountID: "account-1",
SelectedCharacterID: 19,
SourceSlot: 122,
})
if err != nil {
t.Fatalf("Open: %v", err)
}
if !result.AlreadyOpen || result.Consumed || result.SourceChanged {
t.Fatalf("result = %+v", result)
}
inventory, _, err := repos.Inventory.Load(ctx, "19")
if err != nil {
t.Fatalf("load inventory: %v", err)
}
if inventory.Slots["0:122"].Count != 1 {
t.Fatalf("source stack = %+v", inventory.Slots["0:122"])
}
}
func TestOwnerOpenRejectsWrongPVFItem(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "19",
AccountID: "account-1",
}); err != nil {
t.Fatalf("save character: %v", err)
}
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{
"0:122": {ItemID: 42, Count: 1},
},
}); err != nil {
t.Fatalf("save inventory: %v", err)
}
owner, err := NewOwner(repos, catalogStub{
42: {Kind: ItemStackable, PVFPath: "stackable/material/not-a-ticket.stk"},
})
if err != nil {
t.Fatalf("NewOwner: %v", err)
}
_, err = owner.Open(ctx, Command{
AccountID: "account-1",
SelectedCharacterID: 19,
SourceSlot: 122,
})
if !errors.Is(err, ErrTicketInvalid) {
t.Fatalf("Open error = %v, want ErrTicketInvalid", err)
}
}
@@ -0,0 +1,111 @@
package avatarcompound
import (
"context"
"errors"
"fmt"
"strings"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
var (
ErrOwnerUnavailable = errors.New("avatar compound owner is unavailable")
ErrAccountRequired = errors.New("avatar compound account id is required")
ErrCharacterRequired = errors.New("avatar compound character id is required")
ErrCharacterNotFound = errors.New("avatar compound character is not found")
ErrAccountMismatch = errors.New("avatar compound character account mismatch")
ErrInventoryNotFound = errors.New("avatar compound inventory is not found")
ErrProjectorRequired = errors.New("avatar compound mutation projector is required")
)
type Assets struct {
Character *dnfrepo.CharacterRecord
Inventory *dnfrepo.InventoryRecord
}
type Projector func(*Assets) error
type Command struct {
AccountID string
CharacterID string
UpdatedAt time.Time
Project Projector
}
// Owner owns the character authorization and one-character inventory
// transaction used by avatar synthesis. The bridge supplies PVF decisions;
// this owner guarantees that both source avatars, the synthesizer, and every
// produced avatar commit together or all roll back.
type Owner struct {
assets dnfrepo.CharacterAssetUnitOfWork
}
func NewOwner(repositories dnfrepo.Group) (*Owner, error) {
if repositories.CharacterAssets == nil {
return nil, ErrOwnerUnavailable
}
return &Owner{assets: repositories.CharacterAssets}, nil
}
func (o *Owner) Compound(ctx context.Context, command Command) error {
if o == nil || o.assets == nil {
return ErrOwnerUnavailable
}
accountID := strings.TrimSpace(command.AccountID)
characterID := strings.TrimSpace(command.CharacterID)
if accountID == "" {
return ErrAccountRequired
}
if characterID == "" {
return ErrCharacterRequired
}
if command.Project == nil {
return ErrProjectorRequired
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return err
}
now := command.UpdatedAt.UTC()
if now.IsZero() {
now = time.Now().UTC()
}
return o.assets.WithinCharacterAssets(ctx, characterID, func(
characters dnfrepo.CharacterRepository,
inventories dnfrepo.InventoryRepository,
_ dnfrepo.EquipmentRepository,
) error {
if characters == nil || inventories == nil {
return ErrOwnerUnavailable
}
character, found, err := characters.Load(ctx, characterID)
if err != nil {
return err
}
if !found || strings.TrimSpace(character.CharacterID) != characterID {
return fmt.Errorf("%w: character=%s", ErrCharacterNotFound, characterID)
}
if strings.TrimSpace(character.AccountID) != accountID {
return fmt.Errorf("%w: account=%s character=%s", ErrAccountMismatch, accountID, characterID)
}
inventory, found, err := inventories.Load(ctx, characterID)
if err != nil {
return err
}
if !found || strings.TrimSpace(inventory.CharacterID) != characterID || inventory.Slots == nil {
return fmt.Errorf("%w: character=%s", ErrInventoryNotFound, characterID)
}
character = dnfrepo.CloneCharacter(character)
inventory = dnfrepo.CloneInventory(inventory)
if err := command.Project(&Assets{Character: &character, Inventory: &inventory}); err != nil {
return err
}
inventory.UpdatedAt = now
return dnfrepo.SaveInventoryFields(ctx, inventories, inventory, dnfrepo.InventoryFieldSlots)
})
}
@@ -0,0 +1,78 @@
package avatarcompound
import (
"context"
"errors"
"testing"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestOwnerCommitsCompoundInventoryAtomically(t *testing.T) {
ctx := context.Background()
repositories := compoundOwnerRepositories(t, "dnf:1")
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
err = owner.Compound(ctx, Command{AccountID: "dnf:1", CharacterID: "77", Project: func(assets *Assets) error {
delete(assets.Inventory.Slots, "1:4")
assets.Inventory.Slots["1:4"] = dnfrepo.ItemStack{ItemID: 9002, Count: 1}
return nil
}})
if err != nil {
t.Fatal(err)
}
inventory, found, err := repositories.Inventory.Load(ctx, "77")
if err != nil || !found || inventory.Slots["1:4"].ItemID != 9002 {
t.Fatalf("inventory found=%t err=%v slots=%+v", found, err, inventory.Slots)
}
}
func TestOwnerRollsBackCompoundFailure(t *testing.T) {
ctx := context.Background()
repositories := compoundOwnerRepositories(t, "dnf:1")
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
wantErr := errors.New("projection failed")
err = owner.Compound(ctx, Command{AccountID: "dnf:1", CharacterID: "77", Project: func(assets *Assets) error {
delete(assets.Inventory.Slots, "1:4")
return wantErr
}})
if !errors.Is(err, wantErr) {
t.Fatalf("error=%v want=%v", err, wantErr)
}
inventory, found, err := repositories.Inventory.Load(ctx, "77")
if err != nil || !found || inventory.Slots["1:4"].ItemID != 9001 {
t.Fatalf("rollback found=%t err=%v slots=%+v", found, err, inventory.Slots)
}
}
func TestOwnerRejectsCrossAccountCompound(t *testing.T) {
owner, err := NewOwner(compoundOwnerRepositories(t, "dnf:2"))
if err != nil {
t.Fatal(err)
}
err = owner.Compound(context.Background(), Command{AccountID: "dnf:1", CharacterID: "77", Project: func(*Assets) error { return nil }})
if !errors.Is(err, ErrAccountMismatch) {
t.Fatalf("error=%v want=%v", err, ErrAccountMismatch)
}
}
func compoundOwnerRepositories(t *testing.T, accountID string) dnfrepo.Group {
t.Helper()
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "77", AccountID: accountID, Job: "0"}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "77", Slots: map[string]dnfrepo.ItemStack{
"1:4": {ItemID: 9001, Count: 1},
}}); err != nil {
t.Fatal(err)
}
return repositories
}
@@ -0,0 +1,402 @@
package avatarfitting
import (
"context"
"encoding/binary"
"errors"
"fmt"
"math"
"sort"
"strconv"
"strings"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const (
PartCount = 8
settingsScopeSuffix = ":avatar_fitting_room"
settingsUsedParts = "used_parts_total"
)
var (
ErrOwnerUnavailable = errors.New("dnf avatar fitting room owner is unavailable")
ErrCharacterRequired = errors.New("dnf avatar fitting room character is required")
ErrCharacterNotFound = errors.New("dnf avatar fitting room character was not found")
ErrAccountMismatch = errors.New("dnf avatar fitting room account does not match")
ErrTownRequired = errors.New("dnf avatar fitting room requires town state")
ErrRequestInvalid = errors.New("dnf avatar fitting room request is invalid")
ErrEquipmentMismatch = errors.New("dnf avatar fitting room equipped avatar does not match")
ErrTicketMissing = errors.New("dnf avatar fitting room ticket is missing")
ErrRewardSpace = errors.New("dnf avatar fitting room reward inventory space is unavailable")
)
// Change is one of the eight current-client avatar fitting-room rows. The
// durable equipment slot is server-owned; ObjectIndex is echoed only after a
// successful commit so the client can rebind the already refreshed object.
type Change struct {
EquipmentSlot int16
ObjectIndex int32
CurrentItemID int64
OutputItemID int64
PVFPath string
EquipmentType string
}
type Reward struct {
ItemID int64
CountPerCrossing int64
SlotStart int16
SlotEnd int16
StackLimit int64
BuildStack func(slot int16, count int64) (dnfrepo.ItemStack, error)
}
type Command struct {
AccountID string
CharacterID string
InDungeon bool
TicketItemID int64
Changes [PartCount]Change
GaugeThreshold int64
Reward *Reward
}
type Result struct {
Changes [PartCount]Change
TicketSlots []int16
EquipmentSlots []int16
RewardSlots []int16
TicketConsumed int64
RewardGranted int64
GaugeBefore int64
GaugeAfter int64
}
type Owner struct {
settlements dnfrepo.CharacterSettlementUnitOfWork
}
func NewOwner(repositories dnfrepo.Group) (*Owner, error) {
if repositories.CharacterSettlement == nil {
return nil, ErrOwnerUnavailable
}
return &Owner{settlements: repositories.CharacterSettlement}, nil
}
// Commit atomically replaces all eligible worn avatar rows, consumes one
// fitting-room ticket per replaced row and advances the character-local VIP
// gauge. Equipment, inventory and gauge are never allowed to diverge.
func (o *Owner) Commit(ctx context.Context, command Command) (Result, error) {
if o == nil || o.settlements == nil {
return Result{}, ErrOwnerUnavailable
}
command.AccountID = strings.TrimSpace(command.AccountID)
command.CharacterID = strings.TrimSpace(command.CharacterID)
if command.AccountID == "" || command.CharacterID == "" || command.TicketItemID <= 0 {
return Result{}, ErrCharacterRequired
}
if command.InDungeon {
return Result{}, ErrTownRequired
}
var result Result
err := o.settlements.WithinCharacterSettlement(ctx, command.CharacterID, func(tx dnfrepo.Group) error {
if tx.Character == nil || tx.Inventory == nil || tx.Equipment == nil || tx.Settings == nil {
return ErrOwnerUnavailable
}
character, found, err := tx.Character.Load(ctx, command.CharacterID)
if err != nil {
return err
}
if !found {
return ErrCharacterNotFound
}
if strings.TrimSpace(character.AccountID) != command.AccountID {
return ErrAccountMismatch
}
inventory, found, err := tx.Inventory.Load(ctx, command.CharacterID)
if err != nil {
return err
}
if !found {
return ErrTicketMissing
}
equipment, found, err := tx.Equipment.Load(ctx, command.CharacterID)
if err != nil {
return err
}
if !found {
return ErrEquipmentMismatch
}
inventory = dnfrepo.CloneInventory(inventory)
equipment = dnfrepo.CloneEquipment(equipment)
changed := make(map[int16]struct{}, PartCount)
for index, change := range command.Changes {
if change.EquipmentSlot < 0 {
if change.ObjectIndex != -1 || change.CurrentItemID != -1 || change.OutputItemID != 0 {
return fmt.Errorf("%w: absent part=%d", ErrRequestInvalid, index)
}
continue
}
if change.ObjectIndex < 0 || change.CurrentItemID <= 0 || change.OutputItemID <= 0 ||
change.OutputItemID > math.MaxUint32 || strings.TrimSpace(change.PVFPath) == "" ||
strings.TrimSpace(change.EquipmentType) == "" {
return fmt.Errorf("%w: part=%d", ErrRequestInvalid, index)
}
if _, duplicate := changed[change.EquipmentSlot]; duplicate {
return fmt.Errorf("%w: duplicate equipment slot=%d", ErrRequestInvalid, change.EquipmentSlot)
}
key, entry, ok := equipmentEntryForSlot(equipment, change.EquipmentSlot)
if !ok || entry.ItemID != change.CurrentItemID {
return fmt.Errorf("%w: part=%d slot=%d expected=%d", ErrEquipmentMismatch, index, change.EquipmentSlot, change.CurrentItemID)
}
entry.ItemID = change.OutputItemID
if len(entry.RawEntry) < 6 {
return fmt.Errorf("%w: part=%d slot=%d raw=%d", ErrEquipmentMismatch, index, change.EquipmentSlot, len(entry.RawEntry))
}
entry.RawEntry = append([]byte(nil), entry.RawEntry...)
binary.LittleEndian.PutUint32(entry.RawEntry[2:6], uint32(change.OutputItemID))
entry.Extra = cloneStringMap(entry.Extra)
entry.Extra["pvf_path"] = strings.TrimSpace(change.PVFPath)
entry.Extra["equipment_type"] = strings.TrimSpace(change.EquipmentType)
delete(entry.Extra, dnfrepo.CloneAvatarAppearanceExtraKey)
equipment.Entries[key] = entry
changed[change.EquipmentSlot] = struct{}{}
result.Changes[index] = change
result.EquipmentSlots = append(result.EquipmentSlots, change.EquipmentSlot)
}
if len(changed) == 0 {
return ErrRequestInvalid
}
result.TicketConsumed = int64(len(changed))
ticketSlots, err := consumeTickets(&inventory, command.TicketItemID, result.TicketConsumed)
if err != nil {
return err
}
result.TicketSlots = ticketSlots
scope := "character:" + command.CharacterID + settingsScopeSuffix
settings, settingsFound, err := tx.Settings.Load(ctx, scope)
if err != nil {
return err
}
if !settingsFound {
settings = dnfrepo.SettingsRecord{Scope: scope, Values: make(map[string]string)}
} else {
settings = dnfrepo.CloneSettings(settings)
if settings.Values == nil {
settings.Values = make(map[string]string)
}
}
result.GaugeBefore, err = parseNonNegative(settings.Values[settingsUsedParts])
if err != nil {
return err
}
result.GaugeAfter = result.GaugeBefore + result.TicketConsumed
if result.GaugeAfter < result.GaugeBefore {
return ErrRequestInvalid
}
if command.GaugeThreshold > 0 && command.Reward != nil {
beforeCrossings := result.GaugeBefore / command.GaugeThreshold
afterCrossings := result.GaugeAfter / command.GaugeThreshold
if command.Reward.CountPerCrossing <= 0 {
return ErrRequestInvalid
}
result.RewardGranted = (afterCrossings - beforeCrossings) * command.Reward.CountPerCrossing
if result.RewardGranted > 0 {
result.RewardSlots, err = grantReward(&inventory, *command.Reward, result.RewardGranted)
if err != nil {
return err
}
}
}
settings.Values[settingsUsedParts] = strconv.FormatInt(result.GaugeAfter, 10)
now := time.Now().UTC()
inventory.UpdatedAt = now
equipment.UpdatedAt = now
settings.UpdatedAt = now
if err := dnfrepo.SaveInventoryFields(ctx, tx.Inventory, inventory, dnfrepo.InventoryFieldSlots); err != nil {
return err
}
if err := dnfrepo.SaveEquipmentFields(ctx, tx.Equipment, equipment, dnfrepo.EquipmentFieldEntries); err != nil {
return err
}
return dnfrepo.SaveSettingsFields(ctx, tx.Settings, settings, dnfrepo.SettingsFieldValues)
})
return result, err
}
func equipmentEntryForSlot(record dnfrepo.EquipmentRecord, slot int16) (string, dnfrepo.EquipmentEntry, bool) {
for key, entry := range record.Entries {
if entry.SlotIndex == slot {
return key, entry, true
}
}
return "", dnfrepo.EquipmentEntry{}, false
}
func consumeTickets(record *dnfrepo.InventoryRecord, itemID, count int64) ([]int16, error) {
if record == nil || count <= 0 {
return nil, ErrTicketMissing
}
type candidate struct {
key string
slot int16
}
var candidates []candidate
for key, stack := range record.Slots {
listType, slot, ok := parseSlotKey(key)
if ok && listType == dnfrepo.MainInventoryListType && stack.ItemID == itemID && stack.Count > 0 {
candidates = append(candidates, candidate{key: key, slot: slot})
}
}
sort.Slice(candidates, func(i, j int) bool { return candidates[i].slot < candidates[j].slot })
remaining := count
changed := make([]int16, 0, len(candidates))
for _, candidate := range candidates {
if remaining == 0 {
break
}
stack := record.Slots[candidate.key]
used := stack.Count
if used > remaining {
used = remaining
}
stack.Count -= used
remaining -= used
changed = append(changed, candidate.slot)
if stack.Count == 0 {
delete(record.Slots, candidate.key)
continue
}
if stack.Count > math.MaxUint32 || len(stack.RawEntry) < 10 {
return nil, ErrTicketMissing
}
stack.RawEntry = append([]byte(nil), stack.RawEntry...)
binary.LittleEndian.PutUint32(stack.RawEntry[6:10], uint32(stack.Count))
stack.Extra = cloneStringMap(stack.Extra)
stack.Extra["count"] = strconv.FormatInt(stack.Count, 10)
record.Slots[candidate.key] = stack
}
if remaining != 0 {
return nil, ErrTicketMissing
}
return changed, nil
}
func grantReward(record *dnfrepo.InventoryRecord, reward Reward, count int64) ([]int16, error) {
if record == nil || reward.ItemID <= 0 || count <= 0 || reward.BuildStack == nil ||
reward.SlotStart < 0 || reward.SlotEnd < reward.SlotStart {
return nil, ErrRewardSpace
}
// declaredLimit 是 PVF 声明值:<=0 表示没写 [stack limit](不受总量规则约束)。
declaredLimit := reward.StackLimit
if reward.StackLimit <= 0 {
reward.StackLimit = math.MaxUint32
}
// PVF [stack limit] 是这个容器里该道具的总量上限:额度按容器里同 id 总量算,
// 容器里已经有该道具的行时不能再开第二行。
space := dnfrepo.ContainerItemSpace(
record.Slots,
dnfrepo.MainInventoryListType,
reward.ItemID,
declaredLimit,
)
remaining := count
changed := make([]int16, 0, 1)
for slot := reward.SlotStart; slot <= reward.SlotEnd && remaining > 0; slot++ {
key := fmt.Sprintf("%d:%d", dnfrepo.MainInventoryListType, slot)
stack, occupied := record.Slots[key]
if !occupied || stack.ItemID != reward.ItemID || stack.Count <= 0 || stack.Count >= reward.StackLimit ||
len(stack.RawEntry) < 10 || (!stack.ExpireAt.IsZero() && !time.Now().UTC().Before(stack.ExpireAt)) {
continue
}
available := reward.StackLimit - stack.Count
if available > space {
available = space
}
if available <= 0 {
continue
}
added := remaining
if added > available {
added = available
}
stack.Count += added
remaining -= added
space -= added
stack.RawEntry = append([]byte(nil), stack.RawEntry...)
binary.LittleEndian.PutUint32(stack.RawEntry[6:10], uint32(stack.Count))
stack.Extra = cloneStringMap(stack.Extra)
stack.Extra["count"] = strconv.FormatInt(stack.Count, 10)
record.Slots[key] = stack
changed = append(changed, slot)
}
for slot := reward.SlotStart; slot <= reward.SlotEnd && remaining > 0; slot++ {
key := fmt.Sprintf("%d:%d", dnfrepo.MainInventoryListType, slot)
if _, occupied := record.Slots[key]; occupied {
continue
}
if !dnfrepo.CanOpenContainerItemRow(record.Slots, dnfrepo.MainInventoryListType, reward.ItemID, declaredLimit) {
break
}
chunk := remaining
if chunk > reward.StackLimit {
chunk = reward.StackLimit
}
if chunk > space {
chunk = space
}
if chunk <= 0 {
break
}
stack, err := reward.BuildStack(slot, chunk)
if err != nil || stack.ItemID != reward.ItemID || stack.Count != chunk {
return nil, errors.Join(ErrRewardSpace, err)
}
record.Slots[key] = stack
changed = append(changed, slot)
remaining -= chunk
space -= chunk
}
if remaining != 0 {
return nil, ErrRewardSpace
}
return changed, nil
}
func parseSlotKey(key string) (byte, int16, bool) {
parts := strings.Split(strings.TrimSpace(key), ":")
if len(parts) != 2 {
return 0, 0, false
}
list, err1 := strconv.ParseUint(parts[0], 10, 8)
slot, err2 := strconv.ParseInt(parts[1], 10, 16)
return byte(list), int16(slot), err1 == nil && err2 == nil
}
func parseNonNegative(value string) (int64, error) {
if strings.TrimSpace(value) == "" {
return 0, nil
}
parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
if err != nil || parsed < 0 {
return 0, ErrRequestInvalid
}
return parsed, nil
}
func cloneStringMap(source map[string]string) map[string]string {
if len(source) == 0 {
return make(map[string]string)
}
out := make(map[string]string, len(source)+2)
for key, value := range source {
out[key] = value
}
return out
}
@@ -0,0 +1,145 @@
package avatarfitting
import (
"context"
"encoding/binary"
"errors"
"testing"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestCommitAtomicallyConsumesTicketsReplacesAvatarsAndGrantsGaugeReward(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
seedAvatarFittingOwner(t, ctx, repositories, 2)
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
command := testAvatarFittingCommand()
result, err := owner.Commit(ctx, command)
if err != nil {
t.Fatal(err)
}
if result.TicketConsumed != 2 || result.RewardGranted != 1 || result.GaugeBefore != 19 || result.GaugeAfter != 21 {
t.Fatalf("result=%+v", result)
}
inventory, _, _ := repositories.Inventory.Load(ctx, "7")
if _, found := inventory.Slots["0:10"]; found {
t.Fatal("ticket row remained after exact two-ticket consume")
}
if reward, found := inventory.Slots["0:20"]; !found || reward.ItemID != 9000 || reward.Count != 1 {
t.Fatalf("reward=%+v found=%t", reward, found)
}
equipment, _, _ := repositories.Equipment.Load(ctx, "7")
for key, want := range map[string]int64{"0": 2000, "1": 2001} {
entry := equipment.Entries[key]
if entry.ItemID != want || int64(binary.LittleEndian.Uint32(entry.RawEntry[2:6])) != want {
t.Fatalf("equipment[%s]=%+v want=%d", key, entry, want)
}
}
settings, _, _ := repositories.Settings.Load(ctx, "character:7:avatar_fitting_room")
if settings.Values[settingsUsedParts] != "21" {
t.Fatalf("settings=%+v", settings.Values)
}
}
func TestCommitRollsBackEveryAggregateWhenTicketsAreInsufficient(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
seedAvatarFittingOwner(t, ctx, repositories, 1)
owner, _ := NewOwner(repositories)
_, err := owner.Commit(ctx, testAvatarFittingCommand())
if !errors.Is(err, ErrTicketMissing) {
t.Fatalf("error=%v want=%v", err, ErrTicketMissing)
}
inventory, _, _ := repositories.Inventory.Load(ctx, "7")
if stack := inventory.Slots["0:10"]; stack.ItemID != 490702492 || stack.Count != 1 {
t.Fatalf("ticket changed on rollback: %+v", stack)
}
equipment, _, _ := repositories.Equipment.Load(ctx, "7")
if equipment.Entries["0"].ItemID != 1000 || equipment.Entries["1"].ItemID != 1001 {
t.Fatalf("equipment changed on rollback: %+v", equipment.Entries)
}
settings, _, _ := repositories.Settings.Load(ctx, "character:7:avatar_fitting_room")
if settings.Values[settingsUsedParts] != "19" {
t.Fatalf("gauge changed on rollback: %+v", settings.Values)
}
}
func TestCommitRollsBackWhenGaugeRewardHasNoInventorySpace(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
seedAvatarFittingOwner(t, ctx, repositories, 2)
inventory, _, _ := repositories.Inventory.Load(ctx, "7")
inventory.Slots["0:20"] = dnfrepo.ItemStack{ItemID: 1234, Count: 1, RawEntry: make([]byte, 119)}
if err := repositories.Inventory.Save(ctx, inventory); err != nil {
t.Fatal(err)
}
owner, _ := NewOwner(repositories)
_, err := owner.Commit(ctx, testAvatarFittingCommand())
if !errors.Is(err, ErrRewardSpace) {
t.Fatalf("error=%v want=%v", err, ErrRewardSpace)
}
inventory, _, _ = repositories.Inventory.Load(ctx, "7")
if stack := inventory.Slots["0:10"]; stack.ItemID != 490702492 || stack.Count != 2 {
t.Fatalf("tickets changed on reward rollback: %+v", stack)
}
equipment, _, _ := repositories.Equipment.Load(ctx, "7")
if equipment.Entries["0"].ItemID != 1000 || equipment.Entries["1"].ItemID != 1001 {
t.Fatalf("equipment changed on reward rollback: %+v", equipment.Entries)
}
settings, _, _ := repositories.Settings.Load(ctx, "character:7:avatar_fitting_room")
if settings.Values[settingsUsedParts] != "19" {
t.Fatalf("gauge changed on reward rollback: %+v", settings.Values)
}
}
func testAvatarFittingCommand() Command {
command := Command{
AccountID: "dnf:1",
CharacterID: "7",
TicketItemID: 490702492,
GaugeThreshold: 20,
Reward: &Reward{
ItemID: 9000, CountPerCrossing: 1, SlotStart: 20, SlotEnd: 20, StackLimit: 1000,
BuildStack: func(slot int16, count int64) (dnfrepo.ItemStack, error) {
return dnfrepo.ItemStack{ItemID: 9000, Count: count, RawEntry: make([]byte, 119)}, nil
},
},
}
for index := range command.Changes {
command.Changes[index] = Change{EquipmentSlot: -1, ObjectIndex: -1, CurrentItemID: -1}
}
command.Changes[0] = Change{EquipmentSlot: 0, ObjectIndex: 100, CurrentItemID: 1000, OutputItemID: 2000, PVFPath: "equipment/a.equ", EquipmentType: "[hat avatar]"}
command.Changes[1] = Change{EquipmentSlot: 1, ObjectIndex: 101, CurrentItemID: 1001, OutputItemID: 2001, PVFPath: "equipment/b.equ", EquipmentType: "[hair avatar]"}
return command
}
func seedAvatarFittingOwner(t *testing.T, ctx context.Context, repositories dnfrepo.Group, tickets int64) {
t.Helper()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "7", AccountID: "dnf:1", Job: "0"}); err != nil {
t.Fatal(err)
}
ticketRaw := make([]byte, 119)
binary.LittleEndian.PutUint32(ticketRaw[6:10], uint32(tickets))
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "7", Slots: map[string]dnfrepo.ItemStack{
"0:10": {ItemID: 490702492, Count: tickets, RawEntry: ticketRaw},
}}); err != nil {
t.Fatal(err)
}
raw0, raw1 := make([]byte, 119), make([]byte, 119)
binary.LittleEndian.PutUint32(raw0[2:6], 1000)
binary.LittleEndian.PutUint32(raw1[2:6], 1001)
if err := repositories.Equipment.Save(ctx, dnfrepo.EquipmentRecord{CharacterID: "7", Entries: map[string]dnfrepo.EquipmentEntry{
"0": {SlotIndex: 0, ItemID: 1000, RawEntry: raw0},
"1": {SlotIndex: 1, ItemID: 1001, RawEntry: raw1},
}}); err != nil {
t.Fatal(err)
}
if err := repositories.Settings.Save(ctx, dnfrepo.SettingsRecord{Scope: "character:7:avatar_fitting_room", Values: map[string]string{settingsUsedParts: "19"}}); err != nil {
t.Fatal(err)
}
}
@@ -0,0 +1,103 @@
// 本文件定义时装和称号命令计划。
// 计划只记录客户端意图和 owner 预检所需字段,不直接修改外观、称号簿或物品资产。
package avatartitle
import (
"fmt"
"strings"
"longheng.io/server/internal/modules/dnf/alignedcmd"
)
// Command 是 avatartitle handler 交给 avatar/title owner 的最小命令计划。
type Command struct {
Operation string
AccountID string
SelectedCharacterID uint16
ConsumeSlot int16
Slot1 int16
Slot2 int16
RequestItemID int32
TargetSlot int16
TargetItemID int32
MaterialSlot int16
Emblems []EmblemApply
EmblemCount int
ItemSpaceRaw int32
TitleSlot int16
TitleItemID int32
TitleCategory int32
TitleIndex int32
NeedsOwner string
}
// NewCompoundCommand 构造时装合成命令计划。
func NewCompoundCommand(req alignedcmd.Request, parsed CompoundAvatarRequest) Command {
return Command{
Operation: "compound_avatar",
AccountID: strings.TrimSpace(req.AccountID),
SelectedCharacterID: req.SelectedCharacterID,
ConsumeSlot: parsed.ConsumeSlot,
Slot1: parsed.Slot1,
Slot2: parsed.Slot2,
RequestItemID: parsed.RequestItemID,
NeedsOwner: "inventory owner + avatar owner + mutation id + appearance/USERINFO/NOTI order",
}
}
// NewEmblemCommand 构造时装徽章镶嵌命令计划。
func NewEmblemCommand(req alignedcmd.Request, parsed AvatarEmblemRequest) Command {
emblems := append([]EmblemApply(nil), parsed.Emblems...)
return Command{
Operation: "use_emblem",
AccountID: strings.TrimSpace(req.AccountID),
SelectedCharacterID: req.SelectedCharacterID,
TargetSlot: parsed.TargetSlot,
TargetItemID: parsed.TargetItemID,
Emblems: emblems,
EmblemCount: len(emblems),
NeedsOwner: "inventory owner + avatar owner + socket persistence + appearance/USERINFO/NOTI order",
}
}
// NewSocketCommand 构造时装开孔命令计划。
func NewSocketCommand(req alignedcmd.Request, parsed AvatarSocketRequest) Command {
return Command{
Operation: "add_avatar_socket",
AccountID: strings.TrimSpace(req.AccountID),
SelectedCharacterID: req.SelectedCharacterID,
TargetSlot: parsed.TargetSlot,
TargetItemID: parsed.TargetItemID,
MaterialSlot: parsed.MaterialSlot,
NeedsOwner: "inventory owner + avatar owner + socket persistence + appearance/USERINFO/NOTI order",
}
}
// NewTitleBookCommand 构造称号簿放入或取出命令计划。
func NewTitleBookCommand(req alignedcmd.Request, operation string, parsed TitleBookRequest) Command {
return Command{
Operation: operation,
AccountID: strings.TrimSpace(req.AccountID),
SelectedCharacterID: req.SelectedCharacterID,
ItemSpaceRaw: parsed.ItemSpaceRaw,
TitleSlot: parsed.Slot,
TitleItemID: parsed.ItemID,
TitleCategory: parsed.Category,
TitleIndex: parsed.Index,
NeedsOwner: "inventory owner + title owner + mutation id + title book/USERINFO/NOTI order",
}
}
func (c Command) String() string {
base := fmt.Sprintf("account=%q char=%d", c.AccountID, c.SelectedCharacterID)
switch c.Operation {
case "compound_avatar":
return fmt.Sprintf("%s consume=%d slot1=%d slot2=%d requestItem=%d needs=%s", base, c.ConsumeSlot, c.Slot1, c.Slot2, c.RequestItemID, c.NeedsOwner)
case "use_emblem":
return fmt.Sprintf("%s target=(%d,%d) emblems=%d needs=%s", base, c.TargetSlot, c.TargetItemID, c.EmblemCount, c.NeedsOwner)
case "add_avatar_socket":
return fmt.Sprintf("%s target=(%d,%d) material=%d needs=%s", base, c.TargetSlot, c.TargetItemID, c.MaterialSlot, c.NeedsOwner)
default:
return fmt.Sprintf("%s space=%d slot=%d item=%d category=%d index=%d needs=%s", base, c.ItemSpaceRaw, c.TitleSlot, c.TitleItemID, c.TitleCategory, c.TitleIndex, c.NeedsOwner)
}
}
@@ -0,0 +1,2 @@
// Package avatartitle 承接 DNF 时装、徽章和称号簿类 C2S 协议。
package avatartitle
@@ -0,0 +1,130 @@
// 本文件负责时装和称号命令的协议分发。
// 时装合成、徽章、开孔和称号簿都会改动物品与外观状态,当前只允许 owner 只读预检,不开放成功 ACK。
package avatartitle
import (
"context"
"fmt"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/dnfenum"
)
type handler struct{}
// NewHandler 创建时装称号协议处理器。
func NewHandler() alignedcmd.Handler {
return handler{}
}
// Domain 返回时装称号业务域。
func (handler) Domain() dnfenum.AlignedDomain {
return dnfenum.AlignedDomainAvatarTitle
}
// Handle 解析时装称号请求,并在外观、物品刷新链路闭合前禁止成功 ACK。
func (handler) Handle(ctx context.Context, req alignedcmd.Request) (alignedcmd.Result, error) {
switch dnfenum.CmdPacket(req.Opcode) {
case dnfenum.CmdPacketCompoundAvatar:
parsed, err := DecodeCompoundAvatarRequest(req.Body)
cmd := NewCompoundCommand(req, parsed)
return applyOwner(ctx, req, cmd, err), nil
case dnfenum.CmdPacketUseEmblem:
parsed, err := DecodeAvatarEmblemRequest(req.Body)
cmd := NewEmblemCommand(req, parsed)
return applyOwner(ctx, req, cmd, err), nil
case dnfenum.CmdPacketAddAvatarSocket:
parsed, err := DecodeAvatarSocketRequest(req.Body)
cmd := NewSocketCommand(req, parsed)
return applyOwner(ctx, req, cmd, err), nil
case dnfenum.CmdPacketTitleBookPut:
parsed, err := DecodeTitleBookRequest(req.Body)
cmd := NewTitleBookCommand(req, "title_book_put", parsed)
return applyOwner(ctx, req, cmd, err), nil
case dnfenum.CmdPacketTitleBookGet:
parsed, err := DecodeTitleBookRequest(req.Body)
cmd := NewTitleBookCommand(req, "title_book_get", parsed)
return applyOwner(ctx, req, cmd, err), nil
default:
return alignedcmd.Result{
Handled: false,
ResponseAllowed: false,
Operation: "unregistered",
Reason: fmt.Sprintf("avatar_title 模块未注册 opcode %d", req.Opcode),
}, nil
}
}
func applyOwner(ctx context.Context, req alignedcmd.Request, cmd Command, parseErr error) alignedcmd.Result {
if parseErr != nil {
return blockedParsedResult(cmd.Operation, cmd.String(), parseErr)
}
owner, err := NewOwner(req.Repositories)
if err != nil {
return ownerBlockedResult(cmd, err)
}
result, err := owner.Plan(ctx, cmd)
if err != nil {
return ownerBlockedResult(cmd, planError(cmd.Operation, err))
}
return ownerPlannedResult(cmd, result)
}
func blockedParsedResult(operation string, summary string, err error) alignedcmd.Result {
if err != nil {
return alignedcmd.Result{
Handled: true,
ResponseAllowed: false,
Operation: operation,
Reason: fmt.Sprintf("%s 请求体解析失败:%v;禁止回成功包", operation, err),
}
}
return alignedcmd.Result{
Handled: true,
ResponseAllowed: false,
Operation: operation,
Reason: fmt.Sprintf("已解析 %s:%s;外观、称号簿和物品刷新顺序未闭合,禁止回包", operation, summary),
}
}
func ownerBlockedResult(cmd Command, err error) alignedcmd.Result {
return alignedcmd.Result{
Handled: true,
ResponseAllowed: false,
Operation: cmd.Operation,
Reason: fmt.Sprintf("已解析 %s:%s;avatar/title owner 预检未通过:%v;禁止回成功包", cmd.Operation, cmd.String(), err),
}
}
func ownerPlannedResult(cmd Command, result PlanResult) alignedcmd.Result {
return alignedcmd.Result{
Handled: true,
ResponseAllowed: false,
Operation: cmd.Operation,
Reason: fmt.Sprintf(
"已解析 %s:%s;avatartitle owner verified account=%s char=%s inventory=%t equipmentKnown=%t equipEntries=%d avatarSources=%d/%d targetFound=%t targetItem=%d materialFound=%t materialItem=%d emblemMaterials=%d/%d titleFound=%t title=(list=%d slot=%d item=%d category=%d index=%d) requestedOutput=%d;外观/称号簿/USERINFO 刷新顺序未闭合,禁止成功 ACK",
cmd.Operation,
cmd.String(),
result.AccountID,
result.CharacterID,
result.InventoryKnown,
result.EquipmentKnown,
result.EquipmentEntryCount,
result.AvatarSourceFound,
result.AvatarSourceTotal,
result.TargetFound,
result.TargetItemID,
result.MaterialFound,
result.MaterialItemID,
result.EmblemMaterialFound,
result.EmblemMaterialTotal,
result.TitleInventoryFound,
result.TitleInventoryList,
result.TitleInventorySlot,
result.TitleInventoryItemID,
result.TitleBookCategory,
result.TitleBookIndex,
result.RequestedOutputItemID,
),
}
}
@@ -0,0 +1,138 @@
package avatartitle
import (
"context"
"strings"
"testing"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestDecodeTitleBookRequest(t *testing.T) {
got, err := DecodeTitleBookRequest([]byte{
1, 0, 0, 0,
5, 0, 0, 0,
0x44, 0x33, 0x22, 0x11,
2, 0, 0, 0,
3, 0, 0, 0,
})
if err != nil {
t.Fatalf("DecodeTitleBookRequest error = %v", err)
}
if got.ItemSpaceRaw != 1 || got.Slot != 5 || got.ItemID != 0x11223344 || got.Category != 2 || got.Index != 3 {
t.Fatalf("got = %+v", got)
}
}
func TestDecodeCompoundAvatarRequestMatchesCurrentEXEOp99(t *testing.T) {
got, err := DecodeCompoundAvatarRequest([]byte{
0x75, 0x00,
0x15, 0x00, 0x15, 0xec, 0xb4, 0x06,
0x16, 0x00, 0x15, 0xec, 0xb4, 0x06,
0x75, 0xeb, 0xb4, 0x06,
0x02, 0x00,
0x00, 0x00,
})
if err != nil {
t.Fatal(err)
}
if got.ConsumeSlot != 117 || got.Slot1 != 21 || got.ExpectedItemID1 != 112520213 ||
got.Slot2 != 22 || got.ExpectedItemID2 != 112520213 || got.RequestItemID != 112520053 ||
got.AbilityNo != 2 || got.RequestedItemFlag != 0 || got.MaterialClass != 0 {
t.Fatalf("request=%+v", got)
}
}
func TestDecodeAvatarEmblemRequest(t *testing.T) {
got, err := DecodeAvatarEmblemRequest([]byte{
listTypeAvatar,
0x05, 0x00,
0x44, 0x33, 0x22, 0x11,
1,
0x06, 0x00,
0x88, 0x77, 0x66, 0x55,
2,
})
if err != nil {
t.Fatalf("DecodeAvatarEmblemRequest error = %v", err)
}
if got.TargetSlot != 5 || got.TargetItemID != 0x11223344 || len(got.Emblems) != 1 {
t.Fatalf("got = %+v", got)
}
}
func TestHandlerBlocksAvatarTitleAck(t *testing.T) {
got, err := NewHandler().Handle(context.Background(), alignedcmd.Request{
Opcode: uint16(dnfenum.CmdPacketAddAvatarSocket),
Body: []byte{0x05, 0x00, 0x44, 0x33, 0x22, 0x11, 0x06, 0x00},
})
if err != nil {
t.Fatalf("Handle error = %v", err)
}
if !got.Handled || got.ResponseAllowed {
t.Fatalf("result = %+v, want handled without response", got)
}
}
func TestHandlerUsesOwnerPreflightAndStillBlocksAck(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "88", AccountID: "acc"}); err != nil {
t.Fatalf("Character.Save error = %v", err)
}
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "88",
Slots: map[string]dnfrepo.ItemStack{
"1:5": {ItemID: 0x11223344, Count: 1},
"0:6": {ItemID: 7001, Count: 1},
},
}); err != nil {
t.Fatalf("Inventory.Save error = %v", err)
}
if err := repos.Equipment.Save(ctx, dnfrepo.EquipmentRecord{
CharacterID: "88",
Entries: map[string]dnfrepo.EquipmentEntry{
"0": {SlotIndex: 0, ItemID: 9001},
},
}); err != nil {
t.Fatalf("Equipment.Save error = %v", err)
}
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
Opcode: uint16(dnfenum.CmdPacketAddAvatarSocket),
SelectedCharacterID: 88,
Repositories: repos,
Body: []byte{0x05, 0x00, 0x44, 0x33, 0x22, 0x11, 0x06, 0x00},
})
if err != nil {
t.Fatalf("Handle error = %v", err)
}
if !got.Handled || got.ResponseAllowed {
t.Fatalf("result = %+v, want handled without response", got)
}
for _, want := range []string{"avatartitle owner verified", "targetFound=true", "materialFound=true", "equipEntries=1"} {
if !strings.Contains(got.Reason, want) {
t.Fatalf("reason %q missing %q", got.Reason, want)
}
}
}
func TestCommandRecordsAvatarOwnerGap(t *testing.T) {
cmd := NewEmblemCommand(alignedcmd.Request{
AccountID: " acc ",
SelectedCharacterID: 88,
}, AvatarEmblemRequest{
TargetSlot: 5,
TargetItemID: 0x11223344,
Emblems: []EmblemApply{{SocketIndex: 2}},
})
summary := cmd.String()
for _, want := range []string{`account="acc"`, "char=88", "target=(5,287454020)", "emblems=1", "avatar owner", "USERINFO"} {
if !strings.Contains(summary, want) {
t.Fatalf("summary %q missing %q", summary, want)
}
}
}
@@ -0,0 +1,329 @@
// 本文件提供时装和称号命令的只读 owner 预检。
// 当前只读取角色、背包和穿戴快照,外观、称号簿和 USERINFO 刷新链路闭合前不会写库或开放成功 ACK。
package avatartitle
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
var (
ErrOwnerUnavailable = errors.New("avatar title owner unavailable")
ErrCharacterRequired = errors.New("selected character id required")
ErrCharacterNotFound = errors.New("character record not found")
ErrInventoryNotFound = errors.New("inventory record not found")
ErrSlotNotFound = errors.New("inventory slot not found")
ErrItemMismatch = errors.New("inventory item mismatch")
)
// Owner 是时装/称号命令的读模型边界。
type Owner struct {
characters dnfrepo.CharacterRepository
inventory dnfrepo.InventoryRepository
equipment dnfrepo.EquipmentRepository
}
// PlanResult 描述一次只读预检结果,不代表允许向旧客户端回成功包。
type PlanResult struct {
AccountID string
CharacterID string
Operation string
InventoryKnown bool
EquipmentKnown bool
EquipmentEntryCount int
AvatarSourceFound int
AvatarSourceTotal int
TargetFound bool
TargetItemID int64
MaterialFound bool
MaterialItemID int64
EmblemMaterialFound int
EmblemMaterialTotal int
TitleInventoryFound bool
TitleInventoryList byte
TitleInventorySlot int16
TitleInventoryItemID int64
TitleBookCategory int32
TitleBookIndex int32
RequestedOutputItemID int32
}
// NewOwner 创建时装/称号 owner;缺少角色、背包或穿戴仓储时拒绝预检。
func NewOwner(repos dnfrepo.Group) (*Owner, error) {
if repos.Character == nil || repos.Inventory == nil || repos.Equipment == nil {
return nil, ErrOwnerUnavailable
}
return &Owner{characters: repos.Character, inventory: repos.Inventory, equipment: repos.Equipment}, nil
}
// Plan 只读取当前角色相关快照并校验请求槽位,等待 EXE/MCP 回包顺序闭合后再接真实写路径。
func (o *Owner) Plan(ctx context.Context, cmd Command) (PlanResult, error) {
state, err := o.loadState(ctx, cmd)
if err != nil {
return PlanResult{}, err
}
result := PlanResult{
AccountID: state.character.AccountID,
CharacterID: state.characterID,
Operation: cmd.Operation,
InventoryKnown: true,
EquipmentKnown: state.equipmentKnown,
EquipmentEntryCount: len(state.equipment.Entries),
TitleBookCategory: cmd.TitleCategory,
TitleBookIndex: cmd.TitleIndex,
}
switch cmd.Operation {
case "compound_avatar":
result.RequestedOutputItemID = cmd.RequestItemID
found, total, err := state.checkAvatarSources(cmd.ConsumeSlot, cmd.Slot1, cmd.Slot2)
if err != nil {
return PlanResult{}, err
}
result.AvatarSourceFound = found
result.AvatarSourceTotal = total
case "use_emblem":
target, err := state.requireInventorySlot(listTypeAvatar, cmd.TargetSlot, int64(cmd.TargetItemID))
if err != nil {
return PlanResult{}, err
}
result.TargetFound = target.Found
result.TargetItemID = target.ItemID
found, total, err := state.checkEmblems(cmd.Emblems)
if err != nil {
return PlanResult{}, err
}
result.EmblemMaterialFound = found
result.EmblemMaterialTotal = total
case "add_avatar_socket":
target, err := state.requireInventorySlot(listTypeAvatar, cmd.TargetSlot, int64(cmd.TargetItemID))
if err != nil {
return PlanResult{}, err
}
material, err := state.requireInventorySlot(listTypeMain, cmd.MaterialSlot, 0)
if err != nil {
return PlanResult{}, err
}
result.TargetFound = target.Found
result.TargetItemID = target.ItemID
result.MaterialFound = material.Found
result.MaterialItemID = material.ItemID
case "title_book_put":
title, err := state.requireTitleInventory(cmd.ItemSpaceRaw, cmd.TitleSlot, int64(cmd.TitleItemID))
if err != nil {
return PlanResult{}, err
}
result.TitleInventoryFound = title.Found
result.TitleInventoryList = title.ListType
result.TitleInventorySlot = title.SlotIndex
result.TitleInventoryItemID = title.ItemID
case "title_book_get":
title := state.findTitleInventory(cmd.ItemSpaceRaw, cmd.TitleSlot, int64(cmd.TitleItemID))
result.TitleInventoryFound = title.Found
result.TitleInventoryList = title.ListType
result.TitleInventorySlot = title.SlotIndex
result.TitleInventoryItemID = title.ItemID
}
return result, nil
}
type ownerState struct {
characterID string
character dnfrepo.CharacterRecord
inventory dnfrepo.InventoryRecord
equipment dnfrepo.EquipmentRecord
equipmentKnown bool
inventorySlots map[string]dnfrepo.ItemStack
inventoryCargo map[string]dnfrepo.ItemStack
}
type slotCheck struct {
ListType byte
SlotIndex int16
ItemID int64
Found bool
}
func (o *Owner) loadState(ctx context.Context, cmd Command) (ownerState, error) {
if o == nil || o.characters == nil || o.inventory == nil || o.equipment == nil {
return ownerState{}, ErrOwnerUnavailable
}
if cmd.SelectedCharacterID == 0 {
return ownerState{}, ErrCharacterRequired
}
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
character, ok, err := o.characters.Load(ctx, characterID)
if err != nil {
return ownerState{}, err
}
if !ok {
return ownerState{}, ErrCharacterNotFound
}
inventory, ok, err := o.inventory.Load(ctx, characterID)
if err != nil {
return ownerState{}, err
}
if !ok {
return ownerState{}, ErrInventoryNotFound
}
equipment, equipmentKnown, err := o.equipment.Load(ctx, characterID)
if err != nil {
return ownerState{}, err
}
inventory = dnfrepo.CloneInventory(inventory)
if inventory.Slots == nil {
inventory.Slots = make(map[string]dnfrepo.ItemStack)
}
if inventory.Warehouse == nil {
inventory.Warehouse = make(map[string]dnfrepo.ItemStack)
}
equipment = dnfrepo.CloneEquipment(equipment)
if equipment.Entries == nil {
equipment.Entries = make(map[string]dnfrepo.EquipmentEntry)
}
return ownerState{
characterID: characterID,
character: dnfrepo.CloneCharacter(character),
inventory: inventory,
equipment: equipment,
equipmentKnown: equipmentKnown,
inventorySlots: inventory.Slots,
inventoryCargo: inventory.Warehouse,
}, nil
}
func (s ownerState) checkAvatarSources(slots ...int16) (int, int, error) {
found := 0
total := 0
for _, slot := range slots {
if slot < 0 {
continue
}
total++
if _, err := s.requireInventorySlot(listTypeAvatar, slot, 0); err != nil {
return found, total, err
}
found++
}
if total == 0 {
return 0, 0, ErrSlotNotFound
}
return found, total, nil
}
func (s ownerState) checkEmblems(emblems []EmblemApply) (int, int, error) {
found := 0
for _, emblem := range emblems {
if emblem.EmblemSlot < 0 {
continue
}
check, err := s.requireInventorySlot(listTypeMain, emblem.EmblemSlot, int64(emblem.EmblemItemID))
if err != nil {
return found, len(emblems), err
}
if check.Found {
found++
}
}
return found, len(emblems), nil
}
func (s ownerState) requireTitleInventory(spaceRaw int32, slot int16, expectedItemID int64) (slotCheck, error) {
check := s.findTitleInventory(spaceRaw, slot, expectedItemID)
if !check.Found {
return check, fmt.Errorf("%w: title space=%d slot=%d", ErrSlotNotFound, spaceRaw, slot)
}
if expectedItemID != 0 && check.ItemID != expectedItemID {
return check, fmt.Errorf("%w: title space=%d slot=%d want=%d got=%d", ErrItemMismatch, spaceRaw, slot, expectedItemID, check.ItemID)
}
return check, nil
}
func (s ownerState) findTitleInventory(spaceRaw int32, slot int16, expectedItemID int64) slotCheck {
candidates := titleListCandidates(spaceRaw)
firstFound := slotCheck{}
for _, listType := range candidates {
check := s.findInventorySlot(listType, slot)
if !check.Found {
continue
}
if firstFound == (slotCheck{}) {
firstFound = check
}
if expectedItemID == 0 || check.ItemID == expectedItemID {
return check
}
}
return firstFound
}
func (s ownerState) requireInventorySlot(listType byte, slot int16, expectedItemID int64) (slotCheck, error) {
check := s.findInventorySlot(listType, slot)
if !check.Found {
return check, fmt.Errorf("%w: list=%d slot=%d", ErrSlotNotFound, listType, slot)
}
if expectedItemID != 0 && check.ItemID != expectedItemID {
return check, fmt.Errorf("%w: list=%d slot=%d want=%d got=%d", ErrItemMismatch, listType, slot, expectedItemID, check.ItemID)
}
return check, nil
}
func (s ownerState) findInventorySlot(listType byte, slot int16) slotCheck {
if slot < 0 {
return slotCheck{ListType: listType, SlotIndex: slot}
}
items := s.inventorySlots
if listType == listTypePersonalCargo {
items = s.inventoryCargo
}
stack, ok := items[inventoryKey(listType, slot)]
if !ok {
return slotCheck{ListType: listType, SlotIndex: slot}
}
return slotCheck{
ListType: listType,
SlotIndex: slot,
ItemID: stack.ItemID,
Found: true,
}
}
func titleListCandidates(spaceRaw int32) []byte {
candidates := make([]byte, 0, 3)
add := func(listType byte) {
for _, existing := range candidates {
if existing == listType {
return
}
}
candidates = append(candidates, listType)
}
if spaceRaw >= 0 && spaceRaw <= 255 {
add(byte(spaceRaw))
}
add(listTypeMain)
add(listTypeAvatar)
return candidates
}
func inventoryKey(listType byte, slotIndex int16) string {
return fmt.Sprintf("%d:%d", listType, slotIndex)
}
func planError(operation string, err error) error {
if err == nil {
return nil
}
return fmt.Errorf("%s: %w", strings.TrimSpace(operation), err)
}
const (
listTypeMain byte = 0
listTypePersonalCargo byte = 2
)
@@ -0,0 +1,97 @@
package avatartitle
import (
"context"
"errors"
"testing"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestOwnerPlansAvatarSocketFromRepositories(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "88", AccountID: "acc"}); err != nil {
t.Fatalf("Character.Save error = %v", err)
}
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "88",
Slots: map[string]dnfrepo.ItemStack{
"1:5": {ItemID: 0x11223344, Count: 1},
"0:6": {ItemID: 7001, Count: 1},
},
}); err != nil {
t.Fatalf("Inventory.Save error = %v", err)
}
if err := repos.Equipment.Save(ctx, dnfrepo.EquipmentRecord{
CharacterID: "88",
Entries: map[string]dnfrepo.EquipmentEntry{
"0": {SlotIndex: 0, ItemID: 9001},
},
}); err != nil {
t.Fatalf("Equipment.Save error = %v", err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner error = %v", err)
}
got, err := owner.Plan(ctx, Command{
Operation: "add_avatar_socket",
SelectedCharacterID: 88,
TargetSlot: 5,
TargetItemID: 0x11223344,
MaterialSlot: 6,
})
if err != nil {
t.Fatalf("Plan error = %v", err)
}
if got.AccountID != "acc" || got.CharacterID != "88" {
t.Fatalf("identity = (%q,%q)", got.AccountID, got.CharacterID)
}
if !got.TargetFound || got.TargetItemID != 0x11223344 {
t.Fatalf("target = found %t item %d", got.TargetFound, got.TargetItemID)
}
if !got.MaterialFound || got.MaterialItemID != 7001 {
t.Fatalf("material = found %t item %d", got.MaterialFound, got.MaterialItemID)
}
if !got.EquipmentKnown || got.EquipmentEntryCount != 1 {
t.Fatalf("equipment = known %t count %d", got.EquipmentKnown, got.EquipmentEntryCount)
}
}
func TestOwnerRejectsMismatchedAvatarTarget(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "88", AccountID: "acc"}); err != nil {
t.Fatalf("Character.Save error = %v", err)
}
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "88",
Slots: map[string]dnfrepo.ItemStack{
"1:5": {ItemID: 1001, Count: 1},
"0:6": {ItemID: 7001, Count: 1},
},
}); err != nil {
t.Fatalf("Inventory.Save error = %v", err)
}
if err := repos.Equipment.Save(ctx, dnfrepo.EquipmentRecord{CharacterID: "88"}); err != nil {
t.Fatalf("Equipment.Save error = %v", err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner error = %v", err)
}
_, err = owner.Plan(ctx, Command{
Operation: "add_avatar_socket",
SelectedCharacterID: 88,
TargetSlot: 5,
TargetItemID: 0x11223344,
MaterialSlot: 6,
})
if !errors.Is(err, ErrItemMismatch) {
t.Fatalf("Plan error = %v, want ErrItemMismatch", err)
}
}
@@ -0,0 +1,154 @@
// 本文件解析时装和称号类 C2S 请求体。
// 字段顺序来自当前已整理的 C# 旧实现与 Go 侧现有协议号对齐结果,成功回包仍需 EXE/MCP 闭合。
package avatartitle
import (
"encoding/binary"
"fmt"
)
const listTypeAvatar byte = 1
// CompoundAvatarRequest 描述时装合成请求。
type CompoundAvatarRequest struct {
ConsumeSlot int16
Slot1 int16
ExpectedItemID1 int32
Slot2 int16
ExpectedItemID2 int32
RequestItemID int32
AbilityNo uint16
RequestedItemFlag byte
MaterialClass byte
}
// AvatarSocketRequest 描述时装开孔请求。
type AvatarSocketRequest struct {
TargetSlot int16
TargetItemID int32
MaterialSlot int16
}
// AvatarEmblemRequest 描述徽章镶嵌请求。
type AvatarEmblemRequest struct {
TargetSlot int16
TargetItemID int32
Emblems []EmblemApply
}
// EmblemApply 描述单个徽章应用记录。
type EmblemApply struct {
EmblemSlot int16
EmblemItemID int32
SocketIndex byte
}
// TitleBookRequest 描述称号簿放入或取出请求。
type TitleBookRequest struct {
ItemSpaceRaw int32
Slot int16
ItemID int32
Category int32
Index int32
}
// DecodeCompoundAvatarRequest 解析时装合成请求。
func DecodeCompoundAvatarRequest(body []byte) (CompoundAvatarRequest, error) {
if len(body) < 22 {
return CompoundAvatarRequest{}, fmt.Errorf("body too short: got %d want >= 22", len(body))
}
return CompoundAvatarRequest{
ConsumeSlot: readI16(body, 0),
Slot1: readI16(body, 2),
ExpectedItemID1: readI32(body, 4),
Slot2: readI16(body, 8),
ExpectedItemID2: readI32(body, 10),
RequestItemID: readI32(body, 14),
AbilityNo: binary.LittleEndian.Uint16(body[18:20]),
RequestedItemFlag: body[20],
MaterialClass: body[21],
}, nil
}
// DecodeAvatarSocketRequest 解析时装开孔请求。
func DecodeAvatarSocketRequest(body []byte) (AvatarSocketRequest, error) {
if len(body) < 8 {
return AvatarSocketRequest{}, fmt.Errorf("body too short: got %d want >= 8", len(body))
}
return AvatarSocketRequest{
TargetSlot: readI16(body, 0),
TargetItemID: readI32(body, 2),
MaterialSlot: readI16(body, 6),
}, nil
}
// DecodeAvatarEmblemRequest 解析时装徽章请求,兼容首字节为 Avatar list type 的包体。
func DecodeAvatarEmblemRequest(body []byte) (AvatarEmblemRequest, error) {
if len(body) >= 8 && body[0] == listTypeAvatar {
return decodeEmblemAt(body, 1)
}
return decodeEmblemAt(body, 0)
}
// DecodeTitleBookRequest 解析称号簿请求。
func DecodeTitleBookRequest(body []byte) (TitleBookRequest, error) {
if len(body) < 20 {
return TitleBookRequest{}, fmt.Errorf("body too short: got %d want >= 20", len(body))
}
return TitleBookRequest{
ItemSpaceRaw: readI32(body, 0),
Slot: int16(readI32(body, 4)),
ItemID: readI32(body, 8),
Category: readI32(body, 12),
Index: readI32(body, 16),
}, nil
}
func decodeEmblemAt(body []byte, offset int) (AvatarEmblemRequest, error) {
if len(body) < offset+7 {
return AvatarEmblemRequest{}, fmt.Errorf("body too short: got %d want >= %d", len(body), offset+7)
}
count := int(body[offset+6])
req := AvatarEmblemRequest{
TargetSlot: readI16(body, offset),
TargetItemID: readI32(body, offset+2),
Emblems: make([]EmblemApply, 0, count),
}
entryOffset := offset + 7
for i := 0; i < count; i++ {
if entryOffset+7 > len(body) {
return AvatarEmblemRequest{}, fmt.Errorf("emblem entry too short: index %d", i)
}
req.Emblems = append(req.Emblems, EmblemApply{
EmblemSlot: readI16(body, entryOffset),
EmblemItemID: readI32(body, entryOffset+2),
SocketIndex: body[entryOffset+6],
})
entryOffset += 7
}
return req, nil
}
func (r CompoundAvatarRequest) String() string {
return fmt.Sprintf("consume=%d slot1=(%d,%d) slot2=(%d,%d) requestItem=%d ability=%d materialClass=%d", r.ConsumeSlot, r.Slot1, r.ExpectedItemID1, r.Slot2, r.ExpectedItemID2, r.RequestItemID, r.AbilityNo, r.MaterialClass)
}
func (r AvatarSocketRequest) String() string {
return fmt.Sprintf("target=(%d,%d) material=%d", r.TargetSlot, r.TargetItemID, r.MaterialSlot)
}
func (r AvatarEmblemRequest) String() string {
return fmt.Sprintf("target=(%d,%d) emblems=%d", r.TargetSlot, r.TargetItemID, len(r.Emblems))
}
func (r TitleBookRequest) String() string {
return fmt.Sprintf("space=%d slot=%d item=%d category=%d index=%d", r.ItemSpaceRaw, r.Slot, r.ItemID, r.Category, r.Index)
}
func readI16(body []byte, offset int) int16 {
return int16(binary.LittleEndian.Uint16(body[offset:]))
}
func readI32(body []byte, offset int) int32 {
return int32(binary.LittleEndian.Uint32(body[offset:]))
}
@@ -0,0 +1,188 @@
package booster
import (
"context"
"errors"
"fmt"
"strings"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
var (
ErrOwnerUnavailable = errors.New("booster owner is unavailable")
ErrAccountRequired = errors.New("booster account id is required")
ErrCharacterRequired = errors.New("booster character id is required")
ErrCharacterNotFound = errors.New("booster character is not found")
ErrAccountMismatch = errors.New("booster character account mismatch")
ErrInventoryNotFound = errors.New("booster inventory is not found")
ErrProjectorRequired = errors.New("booster mutation projector is required")
)
type Assets struct {
Account *dnfrepo.AccountRecord
AccountInventory *dnfrepo.AccountInventoryRecord
Character *dnfrepo.CharacterRecord
Inventory *dnfrepo.InventoryRecord
EpicBook *dnfrepo.EpicBookRecord
}
type Changes struct {
Account bool
AccountInventory bool
Character bool
Inventory bool
EpicBook bool
}
type Projector func(*Assets) (Changes, error)
type Command struct {
AccountID string
CharacterID string
UpdatedAt time.Time
Project Projector
}
// Owner owns booster authorization and the atomic account state, account
// warehouse and character-inventory commit. Packet and raw-row projection
// stay in bridge.
type Owner struct {
assets dnfrepo.BoosterAssetUnitOfWork
}
func NewOwner(repositories dnfrepo.Group) (*Owner, error) {
if repositories.BoosterAssets == nil {
return nil, ErrOwnerUnavailable
}
return &Owner{assets: repositories.BoosterAssets}, nil
}
func (o *Owner) Open(ctx context.Context, command Command) error {
if o == nil || o.assets == nil {
return ErrOwnerUnavailable
}
accountID := strings.TrimSpace(command.AccountID)
characterID := strings.TrimSpace(command.CharacterID)
if accountID == "" {
return ErrAccountRequired
}
if characterID == "" {
return ErrCharacterRequired
}
if command.Project == nil {
return ErrProjectorRequired
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return err
}
now := command.UpdatedAt.UTC()
if now.IsZero() {
now = time.Now().UTC()
}
return o.assets.WithinBoosterAssets(ctx, accountID, characterID, func(
accountRecords dnfrepo.AccountRepository,
accounts dnfrepo.AccountInventoryRepository,
characters dnfrepo.CharacterRepository,
inventories dnfrepo.InventoryRepository,
_ dnfrepo.EquipmentRepository,
epicBooks dnfrepo.EpicBookRepository,
) error {
if accountRecords == nil || accounts == nil || characters == nil || inventories == nil || epicBooks == nil {
return ErrOwnerUnavailable
}
account, found, err := accountRecords.Load(ctx, accountID)
if err != nil {
return err
}
if !found {
account = dnfrepo.AccountRecord{AccountID: accountID}
}
account = dnfrepo.CloneAccount(account)
account.AccountID = accountID
character, found, err := characters.Load(ctx, characterID)
if err != nil {
return err
}
if !found || strings.TrimSpace(character.CharacterID) != characterID {
return fmt.Errorf("%w: character=%s", ErrCharacterNotFound, characterID)
}
if strings.TrimSpace(character.AccountID) != accountID {
return fmt.Errorf("%w: account=%s character=%s", ErrAccountMismatch, accountID, characterID)
}
character = dnfrepo.CloneCharacter(character)
inventory, found, err := inventories.Load(ctx, characterID)
if err != nil {
return err
}
if !found || strings.TrimSpace(inventory.CharacterID) != characterID || inventory.Slots == nil {
return fmt.Errorf("%w: character=%s", ErrInventoryNotFound, characterID)
}
accountInventory, _, err := accounts.Load(ctx, accountID)
if err != nil {
return err
}
accountInventory = dnfrepo.CloneAccountInventory(accountInventory)
accountInventory.AccountID = accountID
if accountInventory.Slots == nil {
accountInventory.Slots = make(map[string]dnfrepo.ItemStack)
}
inventory = dnfrepo.CloneInventory(inventory)
epicBook, _, err := epicBooks.Load(ctx, accountID)
if err != nil {
return err
}
epicBook = dnfrepo.CloneEpicBook(epicBook)
epicBook.AccountID = accountID
if epicBook.Balances == nil {
epicBook.Balances = make(map[uint32]uint32)
}
changes, err := command.Project(&Assets{
Account: &account,
AccountInventory: &accountInventory,
Character: &character,
Inventory: &inventory,
EpicBook: &epicBook,
})
if err != nil {
return err
}
if changes.Account {
account.UpdatedAt = now
if err := accountRecords.Save(ctx, account); err != nil {
return err
}
}
if changes.Inventory {
inventory.UpdatedAt = now
if err := dnfrepo.SaveInventoryFields(ctx, inventories, inventory, dnfrepo.InventoryFieldSlots); err != nil {
return err
}
}
if changes.Character {
character.UpdatedAt = now
if err := dnfrepo.SaveCharacterFields(ctx, characters, character, dnfrepo.CharacterFieldStats); err != nil {
return err
}
}
if changes.AccountInventory {
accountInventory.UpdatedAt = now
if err := accounts.Save(ctx, accountInventory); err != nil {
return err
}
}
if changes.EpicBook {
epicBook.UpdatedAt = now
if err := epicBooks.Save(ctx, epicBook); err != nil {
return err
}
}
return nil
})
}
@@ -0,0 +1,138 @@
package booster
import (
"context"
"errors"
"testing"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestOwnerCommitsAccountAndCharacterInventoryTogether(t *testing.T) {
ctx := context.Background()
repositories := newOwnerTestRepositories(t, "dnf:1")
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
err = owner.Open(ctx, Command{
AccountID: "dnf:1",
CharacterID: "77",
Project: func(assets *Assets) (Changes, error) {
if assets.Account.Metadata == nil {
assets.Account.Metadata = make(map[string]string)
}
assets.Account.Metadata["premium_expire_97"] = "4102444800"
delete(assets.Inventory.Slots, "0:5")
assets.Inventory.Slots["1:2"] = dnfrepo.ItemStack{ItemID: 9101, Count: 1}
assets.AccountInventory.Slots[dnfrepo.AccountSharedInventorySlotKey(362)] = dnfrepo.ItemStack{ItemID: 10099773, Count: 1}
assets.EpicBook.Balances[710] = 23
return Changes{Account: true, AccountInventory: true, Inventory: true, EpicBook: true}, nil
},
})
if err != nil {
t.Fatal(err)
}
inventory, found, err := repositories.Inventory.Load(ctx, "77")
if err != nil || !found {
t.Fatalf("load inventory found=%v err=%v", found, err)
}
if _, found := inventory.Slots["0:5"]; found {
t.Fatalf("source item was not consumed: %+v", inventory.Slots)
}
if inventory.Slots["1:2"].ItemID != 9101 {
t.Fatalf("avatar reward missing: %+v", inventory.Slots)
}
accountRecord, found, err := repositories.Account.Load(ctx, "dnf:1")
if err != nil || !found || accountRecord.Metadata["premium_expire_97"] != "4102444800" {
t.Fatalf("account premium=%+v found=%v err=%v", accountRecord, found, err)
}
account, found, err := repositories.AccountInventory.Load(ctx, "dnf:1")
if err != nil || !found {
t.Fatalf("load account inventory found=%v err=%v", found, err)
}
if account.Slots[dnfrepo.AccountSharedInventorySlotKey(362)].ItemID != 10099773 {
t.Fatalf("shared reward missing: %+v", account.Slots)
}
book, found, err := repositories.EpicBook.Load(ctx, "dnf:1")
if err != nil || !found || book.Balances[710] != 23 {
t.Fatalf("epic book=%+v found=%v err=%v", book, found, err)
}
}
func TestOwnerRollsBackProjectorFailure(t *testing.T) {
ctx := context.Background()
repositories := newOwnerTestRepositories(t, "dnf:1")
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
wantErr := errors.New("projection failed")
err = owner.Open(ctx, Command{
AccountID: "dnf:1",
CharacterID: "77",
Project: func(assets *Assets) (Changes, error) {
if assets.Account.Metadata == nil {
assets.Account.Metadata = make(map[string]string)
}
assets.Account.Metadata["premium_expire_97"] = "4102444800"
delete(assets.Inventory.Slots, "0:5")
assets.EpicBook.Balances[710] = 23
return Changes{Inventory: true, EpicBook: true}, wantErr
},
})
if !errors.Is(err, wantErr) {
t.Fatalf("Open() error=%v want=%v", err, wantErr)
}
inventory, found, err := repositories.Inventory.Load(ctx, "77")
if err != nil || !found {
t.Fatalf("load inventory found=%v err=%v", found, err)
}
if inventory.Slots["0:5"].Count != 1 {
t.Fatalf("failed projection leaked mutation: %+v", inventory.Slots)
}
if accountRecord, found, err := repositories.Account.Load(ctx, "dnf:1"); err != nil || found || len(accountRecord.Metadata) != 0 {
t.Fatalf("failed projection leaked account=%+v found=%v err=%v", accountRecord, found, err)
}
if book, found, err := repositories.EpicBook.Load(ctx, "dnf:1"); err != nil || found || len(book.Balances) != 0 {
t.Fatalf("failed projection leaked epic book=%+v found=%v err=%v", book, found, err)
}
}
func TestOwnerRejectsCrossAccountCharacter(t *testing.T) {
ctx := context.Background()
repositories := newOwnerTestRepositories(t, "dnf:2")
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
err = owner.Open(ctx, Command{
AccountID: "dnf:1",
CharacterID: "77",
Project: func(*Assets) (Changes, error) {
return Changes{}, nil
},
})
if !errors.Is(err, ErrAccountMismatch) {
t.Fatalf("Open() error=%v want=%v", err, ErrAccountMismatch)
}
}
func newOwnerTestRepositories(t *testing.T, characterAccountID string) dnfrepo.Group {
t.Helper()
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "77", AccountID: characterAccountID}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "77",
Slots: map[string]dnfrepo.ItemStack{
"0:5": {ItemID: 490701318, Count: 1},
},
}); err != nil {
t.Fatal(err)
}
return repositories
}
+91
View File
@@ -0,0 +1,91 @@
package cargo
import "encoding/binary"
const (
listTypeMain byte = 0
msgItemListUpdate uint16 = 0x000E
msgCeraUpdate uint16 = 0x0035
commonUpdateEntrySize = 0x77
)
type packetWriter struct {
data []byte
}
func (w *packetWriter) writeByte(value byte) {
w.data = append(w.data, value)
}
func (w *packetWriter) writeZero(count int) {
if count <= 0 {
return
}
w.data = append(w.data, make([]byte, count)...)
}
func (w *packetWriter) writeUint16(value uint16) {
var buf [2]byte
binary.LittleEndian.PutUint16(buf[:], value)
w.data = append(w.data, buf[:]...)
}
func (w *packetWriter) writeInt16(value int16) {
w.writeUint16(uint16(value))
}
func (w *packetWriter) writeInt32(value int32) {
var buf [4]byte
binary.LittleEndian.PutUint32(buf[:], uint32(value))
w.data = append(w.data, buf[:]...)
}
func (w *packetWriter) bytes() []byte {
return append([]byte(nil), w.data...)
}
func buildSuccessAck() []byte {
return []byte{1}
}
func buildCargoGoldAck(cargoGold int64) []byte {
var writer packetWriter
writer.writeByte(1)
writer.writeInt32(clampInt32(cargoGold))
return writer.bytes()
}
func buildGoldUpdateBody(gold int64) []byte {
var writer packetWriter
writer.writeByte(listTypeMain)
writer.writeUint16(1)
writeCommonUpdateEntry(&writer, 0, 0, clampInt32(gold))
return writer.bytes()
}
func buildCeraUpdateBody(cera int64) []byte {
var writer packetWriter
writer.writeByte(1)
writer.writeInt32(clampInt32(cera))
writer.writeInt32(0)
writer.writeInt32(0)
return writer.bytes()
}
func writeCommonUpdateEntry(writer *packetWriter, slot int16, itemID int32, countOrValue int32) {
writer.writeInt16(slot)
writer.writeInt32(itemID)
writer.writeInt32(countOrValue)
writer.writeZero(commonUpdateEntrySize - 2 - 4 - 4)
}
func clampInt32(value int64) int32 {
if value > 2147483647 {
return 2147483647
}
if value < -2147483648 {
return -2147483648
}
return int32(value)
}
@@ -0,0 +1,60 @@
// 本文件定义账号仓库命令计划。
// 计划只记录协议解析后的业务意图,不直接修改金币、仓库或角色状态。
package cargo
import (
"fmt"
"strings"
"longheng.io/server/internal/modules/dnf/alignedcmd"
)
// MoneyDirection 表示金币在角色钱包和账号仓库之间的流向。
type MoneyDirection string
const (
MoneyDeposit MoneyDirection = "deposit"
MoneyWithdraw MoneyDirection = "withdraw"
)
// Command 是 cargo handler 交给后续 owner 的最小命令计划。
// 金币属于关键资产;在 wallet/account-cargo owner 和幂等事务落地前,该计划不能直接变成成功回包。
type Command struct {
Operation string
AccountID string
SelectedCharacterID uint16
MoneyDirection MoneyDirection
Amount int64
NeedsOwner string
}
// NewAccountCommand 构造账号仓库创建或升级命令计划。
// 当前只用于日志和后续 owner 接入点,不写仓库扩展状态。
func NewAccountCommand(req alignedcmd.Request, operation string) Command {
return Command{
Operation: operation,
AccountID: strings.TrimSpace(req.AccountID),
SelectedCharacterID: req.SelectedCharacterID,
NeedsOwner: "account-cargo owner + durable idempotency",
}
}
// NewMoneyCommand 构造金币存取命令计划。
// 它只消费已经解析出的金额,真正扣减/增加必须由可靠 owner 在一个事务语义中完成。
func NewMoneyCommand(req alignedcmd.Request, direction MoneyDirection, money GoldRequest) Command {
return Command{
Operation: string(direction) + "_money",
AccountID: strings.TrimSpace(req.AccountID),
SelectedCharacterID: req.SelectedCharacterID,
MoneyDirection: direction,
Amount: int64(money.Amount),
NeedsOwner: "wallet owner + account-cargo owner + mutation id",
}
}
func (c Command) String() string {
if c.Amount > 0 {
return fmt.Sprintf("account=%q char=%d direction=%s amount=%d needs=%s", c.AccountID, c.SelectedCharacterID, c.MoneyDirection, c.Amount, c.NeedsOwner)
}
return fmt.Sprintf("account=%q char=%d needs=%s", c.AccountID, c.SelectedCharacterID, c.NeedsOwner)
}
+2
View File
@@ -0,0 +1,2 @@
// Package cargo 承接 DNF 账号仓库创建、升级和金币存取类 C2S 协议。
package cargo
@@ -0,0 +1,216 @@
package cargo
import (
"context"
"fmt"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
)
type handler struct{}
func NewHandler() alignedcmd.Handler {
return handler{}
}
func (handler) Domain() dnfenum.AlignedDomain {
return dnfenum.AlignedDomainCargo
}
func (handler) Handle(ctx context.Context, req alignedcmd.Request) (alignedcmd.Result, error) {
switch dnfenum.CmdPacket(req.Opcode) {
case dnfenum.CmdPacketCreateAccountCargo:
cmd := NewAccountCommand(req, "create_account_cargo")
return handleAccountPlan(ctx, req, cmd)
case dnfenum.CmdPacketUpgradeAccountCargo:
cmd := NewAccountCommand(req, "upgrade_account_cargo")
return handleAccountPlan(ctx, req, cmd)
case dnfenum.CmdPacketDepositMoney:
parsed, err := DecodeGoldRequest(req.Body)
cmd := NewMoneyCommand(req, MoneyDeposit, parsed)
return handleMoneyPlan(ctx, req, cmd, err)
case dnfenum.CmdPacketWithdrawMoney:
parsed, err := DecodeGoldRequest(req.Body)
cmd := NewMoneyCommand(req, MoneyWithdraw, parsed)
return handleMoneyPlan(ctx, req, cmd, err)
default:
return alignedcmd.Result{
Handled: false,
ResponseAllowed: false,
Operation: "unregistered",
Reason: fmt.Sprintf("cargo module has no opcode %d handler", req.Opcode),
}, nil
}
}
func handleAccountPlan(ctx context.Context, req alignedcmd.Request, cmd Command) (alignedcmd.Result, error) {
owner, err := NewOwner(req.Repositories)
if err != nil {
return ownerBlockedResult(cmd.Operation, cmd.String(), err), nil
}
result, err := owner.ApplyAccount(ctx, cmd.Operation, AccountCommand{
AccountID: cmd.AccountID,
SelectedCharacterID: cmd.SelectedCharacterID,
})
if err != nil {
return ownerBlockedResult(cmd.Operation, cmd.String(), err), nil
}
return ownerAppliedResult(req.Opcode, cmd.Operation, cmd.String(), result), nil
}
func handleMoneyPlan(ctx context.Context, req alignedcmd.Request, cmd Command, parseErr error) (alignedcmd.Result, error) {
if parseErr != nil {
return blockedParsedResult(cmd.Operation, cmd.String(), parseErr), nil
}
owner, err := NewOwner(req.Repositories)
if err != nil {
return ownerBlockedResult(cmd.Operation, cmd.String(), err), nil
}
result, err := owner.ApplyMoney(ctx, MoneyCommand{
AccountID: cmd.AccountID,
SelectedCharacterID: cmd.SelectedCharacterID,
Direction: cmd.MoneyDirection,
Amount: cmd.Amount,
})
if err != nil {
return ownerBlockedResult(cmd.Operation, cmd.String(), err), nil
}
return ownerAppliedResult(req.Opcode, cmd.Operation, cmd.String(), result), nil
}
func blockedResult(operation string, summary string) alignedcmd.Result {
return alignedcmd.Result{
Handled: true,
ResponseAllowed: false,
Operation: operation,
Reason: fmt.Sprintf("parsed %s: %s; success ACK is blocked until cargo asset writes are closed", operation, summary),
}
}
func blockedParsedResult(operation string, summary string, err error) alignedcmd.Result {
if err != nil {
return alignedcmd.Result{
Handled: true,
ResponseAllowed: false,
Operation: operation,
Reason: fmt.Sprintf("%s body parse failed: %v; success ACK is blocked", operation, err),
}
}
return blockedResult(operation, summary)
}
func ownerBlockedResult(operation string, summary string, err error) alignedcmd.Result {
return alignedcmd.Result{
Handled: true,
ResponseAllowed: false,
Operation: operation,
Reason: fmt.Sprintf("parsed %s: %s; cargo owner preflight failed: %v; success ACK is blocked", operation, summary, err),
}
}
func ownerAppliedResult(opcode uint16, operation string, summary string, result PlanResult) alignedcmd.Result {
responses := cargoUpperResponses(opcode, operation, result)
postActions := make([]alignedcmd.PostAction, 0, 1)
if operation == "create_account_cargo" || operation == "upgrade_account_cargo" {
// The list-12 body belongs to the bridge because it is decoded by the
// current EXE's item-list reader. Do not send the former empty/replayed
// cargo body here: it lost account-owned rows and mixed owner state.
//
// 开通/升级都改变了金库容量,必须先重建仓库底板再发列表:当前 EXE 的面板格子
// 是建面板时按容量铺的,只更新列表数据不会把锁去掉(现场 2026-09-18:扩容后
// 格子还画着锁、实际能放东西,切一下仓库页才正常)。
postActions = append(postActions, alignedcmd.PostActionResetCargoPadBeforeItemLists)
if result.Cost.Kind == CostMaterial {
// The material is a real list-0 stack. Refresh the character-owned
// containers after its durable transaction as well as list 12.
postActions = append(postActions, alignedcmd.PostActionRefreshSelectedItemContainers)
}
postActions = append(postActions, alignedcmd.PostActionRefreshSelectedAccountCargo)
}
out := alignedcmd.Result{
Handled: true,
ResponseAllowed: len(responses) > 0,
Operation: operation,
Reason: fmt.Sprintf(
"parsed %s: %s; cargo owner applied account=%s char=%s direction=%s amount=%d charGold=%d charCera=%d cargoGold=%d cargoLevel=%d cargoCreated=%t cost=%s/%d changed=%t; C# order response opened",
operation,
summary,
result.AccountID,
result.CharacterID,
result.Direction,
result.Amount,
result.CharacterGold,
result.CharacterCera,
result.CargoGold,
result.CargoLevel,
result.CargoCreated,
result.Cost.Kind,
result.Cost.Amount,
result.Changed,
),
UpperResponses: responses,
PostActions: postActions,
}
// 金库创建/升级用点券付款时,这就是一次"点券消费"。模块自己知道花了多少、
// 花在什么上,所以在这里上报给 bridge 记账(点券消费记录)。
// CharacterCera 是事务提交后的权威余额,扣费失败时 Changed=false 不会走到这里。
if result.Changed && result.Cost.Kind == CostCera && result.Cost.Amount > 0 {
out.CeraSpent = result.Cost.Amount
out.CeraAfter = result.CharacterCera
out.CeraSpentNote = cargoCeraSpentNote(operation, result)
}
return out
}
// cargoCeraSpentNote 给点券消费记录写一句人话,说明这笔钱花在哪个金库阶段。
func cargoCeraSpentNote(operation string, result PlanResult) string {
switch operation {
case "create_account_cargo":
return fmt.Sprintf("金库创建(开通账号金库,升级到 %d 阶段)", result.CargoLevel)
case "upgrade_account_cargo":
return fmt.Sprintf("金库升级(账号金库升到 %d 阶段)", result.CargoLevel)
default:
return operation
}
}
func cargoUpperResponses(opcode uint16, operation string, result PlanResult) []alignedcmd.UpperResponse {
switch operation {
case "deposit_money", "withdraw_money":
return []alignedcmd.UpperResponse{
class1Response(opcode, buildCargoGoldAck(result.CargoGold)),
class0Response(msgItemListUpdate, buildGoldUpdateBody(result.CharacterGold)),
}
case "create_account_cargo", "upgrade_account_cargo":
responses := []alignedcmd.UpperResponse{class1Response(opcode, buildSuccessAck())}
switch result.Cost.Kind {
case CostGold:
responses = append(responses, class0Response(msgItemListUpdate, buildGoldUpdateBody(result.CharacterGold)))
case CostCera:
responses = append(responses, class0Response(msgCeraUpdate, buildCeraUpdateBody(result.CharacterCera)))
}
return responses
default:
return nil
}
}
func class1Response(opcode uint16, body []byte) alignedcmd.UpperResponse {
return alignedcmd.UpperResponse{
MsgID: opcode,
Body: body,
Classification: dnfproto.DefaultChannelClassification,
AllowCodec: true,
}
}
func class0Response(msgID uint16, body []byte) alignedcmd.UpperResponse {
return alignedcmd.UpperResponse{
MsgID: msgID,
Body: body,
Classification: 0,
AllowCodec: true,
}
}
@@ -0,0 +1,182 @@
package cargo
import (
"context"
"strings"
"testing"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestDecodeGoldRequest(t *testing.T) {
got, err := DecodeGoldRequest([]byte{0x40, 0x42, 0x0F, 0x00})
if err != nil {
t.Fatalf("DecodeGoldRequest error = %v", err)
}
if got.Amount != 1000000 {
t.Fatalf("amount = %d, want 1000000", got.Amount)
}
}
func TestHandlerBlocksCargoSuccessAck(t *testing.T) {
got, err := NewHandler().Handle(context.Background(), alignedcmd.Request{
Opcode: uint16(dnfenum.CmdPacketDepositMoney),
Body: []byte{0x01, 0x00, 0x00, 0x00},
AccountID: " acc-1 ",
SelectedCharacterID: 12,
})
if err != nil {
t.Fatalf("Handle error = %v", err)
}
if !got.Handled || got.ResponseAllowed {
t.Fatalf("result = %+v, want handled without response", got)
}
if got.Operation != "deposit_money" {
t.Fatalf("operation = %q", got.Operation)
}
if !strings.Contains(got.Reason, `account="acc-1"`) || !strings.Contains(got.Reason, "char=12") || !strings.Contains(got.Reason, "amount=1") {
t.Fatalf("reason should include command plan, got %q", got.Reason)
}
}
func TestHandlerDepositUsesOwnerAndReturnsAckThenGoldRefresh(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "acc-5",
Metadata: map[string]string{
"account_cargo_gold": "400",
"account_cargo_level": "1",
"account_cargo_created": "true",
},
}); err != nil {
t.Fatalf("save account: %v", err)
}
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "90",
AccountID: "acc-5",
Stats: map[string]int64{"gold": 800},
}); err != nil {
t.Fatalf("save character: %v", err)
}
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
Opcode: uint16(dnfenum.CmdPacketDepositMoney),
Body: []byte{0x2C, 0x01, 0x00, 0x00},
AccountID: "acc-5",
SelectedCharacterID: 90,
Repositories: repos,
})
if err != nil {
t.Fatalf("Handle error = %v", err)
}
if !got.Handled || !got.ResponseAllowed {
t.Fatalf("result = %+v, want handled with response", got)
}
if !strings.Contains(got.Reason, "cargo owner applied") || !strings.Contains(got.Reason, "charGold=500") || !strings.Contains(got.Reason, "cargoGold=700") {
t.Fatalf("reason should include owner mutation, got %q", got.Reason)
}
if len(got.UpperResponses) != 2 {
t.Fatalf("response count = %d, want 2", len(got.UpperResponses))
}
ack := got.UpperResponses[0]
if ack.MsgID != uint16(dnfenum.CmdPacketDepositMoney) || ack.Classification != dnfproto.DefaultChannelClassification || !ack.AllowCodec {
t.Fatalf("ack = %+v", ack)
}
if want := []byte{1, 0xBC, 0x02, 0x00, 0x00}; string(ack.Body) != string(want) {
t.Fatalf("ack body = % X, want % X", ack.Body, want)
}
refresh := got.UpperResponses[1]
if refresh.MsgID != msgItemListUpdate || refresh.Classification != 0 || !refresh.AllowCodec {
t.Fatalf("refresh = %+v", refresh)
}
if len(refresh.Body) != 3+commonUpdateEntrySize || refresh.Body[0] != 0 || refresh.Body[1] != 1 || refresh.Body[2] != 0 {
t.Fatalf("gold refresh header/len = len=%d body=% X", len(refresh.Body), refresh.Body[:min(len(refresh.Body), 12)])
}
if refresh.Body[9] != 0xF4 || refresh.Body[10] != 0x01 {
t.Fatalf("gold refresh value bytes = % X, want 500", refresh.Body[9:13])
}
account, ok, err := repos.Account.Load(ctx, "acc-5")
if err != nil || !ok {
t.Fatalf("load account ok=%t err=%v", ok, err)
}
if got := account.Metadata["account_cargo_gold"]; got != "700" {
t.Fatalf("cargo gold = %q, want 700", got)
}
}
func TestHandlerCreateCargoUsesOwnerAndReturnsAckCostAndCurrentCargoPostAction(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{AccountID: "acc-6"}); err != nil {
t.Fatalf("save account: %v", err)
}
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "91",
AccountID: "acc-6",
Stats: map[string]int64{"gold": 500000},
}); err != nil {
t.Fatalf("save character: %v", err)
}
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
Opcode: uint16(dnfenum.CmdPacketCreateAccountCargo),
AccountID: " acc-6 ",
SelectedCharacterID: 91,
Repositories: repos,
})
if err != nil {
t.Fatalf("Handle error = %v", err)
}
if !got.Handled || !got.ResponseAllowed {
t.Fatalf("result = %+v, want handled with response", got)
}
if !strings.Contains(got.Reason, "cargo owner applied") || !strings.Contains(got.Reason, "cargoLevel=1") || !strings.Contains(got.Reason, "cargoCreated=true") {
t.Fatalf("reason should include account mutation, got %q", got.Reason)
}
if len(got.UpperResponses) != 2 {
t.Fatalf("response count = %d, want 2", len(got.UpperResponses))
}
if ack := got.UpperResponses[0]; ack.MsgID != uint16(dnfenum.CmdPacketCreateAccountCargo) || string(ack.Body) != string([]byte{1}) || ack.Classification != dnfproto.DefaultChannelClassification {
t.Fatalf("ack = %+v", ack)
}
if gold := got.UpperResponses[1]; gold.MsgID != msgItemListUpdate || gold.Classification != 0 || len(gold.Body) != 3+commonUpdateEntrySize {
t.Fatalf("gold refresh = %+v len=%d", gold, len(gold.Body))
}
if len(got.PostActions) != 2 ||
got.PostActions[0] != alignedcmd.PostActionResetCargoPadBeforeItemLists ||
got.PostActions[1] != alignedcmd.PostActionRefreshSelectedAccountCargo {
t.Fatalf("post actions = %+v, want cargo-pad reset then current account cargo refresh", got.PostActions)
}
character, ok, err := repos.Character.Load(ctx, "91")
if err != nil || !ok {
t.Fatalf("load character ok=%t err=%v", ok, err)
}
if got := character.Stats["gold"]; got != 400000 {
t.Fatalf("character gold = %d, want 400000", got)
}
}
func min(a int, b int) int {
if a < b {
return a
}
return b
}
func TestMoneyCommandRecordsOwnerGap(t *testing.T) {
cmd := NewMoneyCommand(alignedcmd.Request{
AccountID: " acc-2 ",
SelectedCharacterID: 99,
}, MoneyWithdraw, GoldRequest{Amount: 500})
if cmd.AccountID != "acc-2" || cmd.SelectedCharacterID != 99 || cmd.Amount != 500 || cmd.MoneyDirection != MoneyWithdraw {
t.Fatalf("cmd = %+v", cmd)
}
if !strings.Contains(cmd.String(), "mutation id") {
t.Fatalf("command plan must name reliable-write gap: %s", cmd.String())
}
}
+498
View File
@@ -0,0 +1,498 @@
package cargo
import (
"context"
"errors"
"fmt"
"sort"
"strconv"
"strings"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
var (
ErrOwnerUnavailable = errors.New("cargo owner unavailable")
ErrAccountRequired = errors.New("account id required")
ErrCharacterRequired = errors.New("selected character id required")
ErrAccountNotFound = errors.New("account record not found")
ErrCharacterNotFound = errors.New("character record not found")
ErrGoldInsufficient = errors.New("gold is insufficient")
ErrCargoExists = errors.New("account cargo already exists")
ErrCargoNotCreated = errors.New("account cargo is not created")
ErrCargoMaxLevel = errors.New("account cargo is already max level")
ErrCargoCostMissing = errors.New("account cargo upgrade cost owner is missing")
ErrCargoMaterialInsufficient = errors.New("account cargo upgrade material is insufficient")
// ErrCargoUpgradeToolRequired 表示该档没有金库窗口自己的费用,只能买商城的
// 帐号金库升级工具。op306 必须失败关闭,否则新接上的高档位会变成免费升级。
ErrCargoUpgradeToolRequired = errors.New("account cargo tier requires the cera shop upgrade tool")
)
const accountCargoCreateGoldCost int64 = 100000
const accountCargoVoidMagicStoneItemID int64 = 3299
const accountCargoVoidMagicStoneCost int64 = 250
// accountCargoTiers 是账号金库的完整容量阶梯,一档 8 格。
//
// 2026-08-25 现网证据(旧树 runtime/logs/packet_log.txt):客户端按顺序消耗 PVF
// 里的 12 个点券工具 stackable/cash/chn_account_cargo/account_cargo1..12.stk
// (cerashop.etc 商品 100534 / 102045 / 102108 / 102316 / 102659 /
// 103665-103671),每个工具一档 8 格;1->8、16->24、24->32 三档不用工具,走
// op306 的金币和无色小晶块。观测到的 8->16(tool1)、32->40(tool2)、
// 40->48(tool3)、48->56(tool4)、56->64(tool5) 全部成功落库,容量 64 时客户端
// 请求 tool6 也就是 64->72。12 个工具因此把阶梯接到 120 格;此前表只写到 64,
// tool6 被 errCurrentCeraShopProductUnavailable 拒绝,客户端把失败码 4 显示成
// "物品栏空间不足"。
var accountCargoTiers = []int64{1, 8, 16, 24, 32, 40, 48, 56, 64, 72, 80, 88, 96, 104, 112, 120}
type AccountCommand struct {
AccountID string
SelectedCharacterID uint16
}
type MoneyCommand struct {
AccountID string
SelectedCharacterID uint16
Direction MoneyDirection
Amount int64
}
type CostKind string
const (
CostNone CostKind = ""
CostGold CostKind = "gold"
CostCera CostKind = "cera"
CostMaterial CostKind = "material"
)
type CostResult struct {
Kind CostKind
Amount int64
}
// PlanResult is a read-only validation result; it does not mean cargo state
// or gold has been changed.
type PlanResult struct {
AccountID string
CharacterID string
Operation string
Direction MoneyDirection
Amount int64
CharacterGold int64
CharacterCera int64
CargoGold int64
CargoLevel int64
CargoCreated bool
Cost CostResult
Changed bool
}
type Owner struct {
accounts dnfrepo.AccountRepository
characters dnfrepo.CharacterRepository
assets dnfrepo.RentalAssetUnitOfWork
}
func NewOwner(repos dnfrepo.Group) (*Owner, error) {
if repos.Account == nil || repos.Character == nil || repos.RentalAssets == nil {
return nil, ErrOwnerUnavailable
}
return &Owner{accounts: repos.Account, characters: repos.Character, assets: repos.RentalAssets}, nil
}
func (o *Owner) PlanAccount(ctx context.Context, operation string, cmd AccountCommand) (PlanResult, error) {
account, err := o.loadAccount(ctx, cmd.AccountID)
if err != nil {
return PlanResult{}, err
}
return PlanResult{
AccountID: account.AccountID,
Operation: operation,
CargoGold: metadataInt(account.Metadata, "account_cargo_gold"),
CargoLevel: metadataInt(account.Metadata, "account_cargo_level"),
CargoCreated: metadataBool(account.Metadata, "account_cargo_created"),
}, nil
}
// ApplyAccount 写入账号金库创建/扩展状态,并在需要金币费用时同步扣角色金币。
// 当前还没有统一 wallet/account-cargo 事务,保存账号失败时会补偿回滚角色金币,成功 ACK 仍由 handler 阻断。
func (o *Owner) ApplyAccount(ctx context.Context, operation string, cmd AccountCommand) (PlanResult, error) {
if o == nil || o.assets == nil {
return PlanResult{}, ErrOwnerUnavailable
}
accountID := strings.TrimSpace(cmd.AccountID)
if accountID == "" {
return PlanResult{}, ErrAccountRequired
}
if cmd.SelectedCharacterID == 0 {
return PlanResult{}, ErrCharacterRequired
}
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
var result PlanResult
err := o.assets.WithinRentalAssets(ctx, accountID, characterID, func(accounts dnfrepo.AccountRepository, characters dnfrepo.CharacterRepository, inventoryRepo dnfrepo.InventoryRepository, _ dnfrepo.EquipmentRepository) error {
account, found, err := accounts.Load(ctx, accountID)
if err != nil {
return err
}
if !found {
return ErrAccountNotFound
}
character, found, err := characters.Load(ctx, characterID)
if err != nil {
return err
}
if !found {
return ErrCharacterNotFound
}
if owner := strings.TrimSpace(character.AccountID); owner != "" && owner != accountID {
return ErrAccountRequired
}
account = ensureAccountMetadata(dnfrepo.CloneAccount(account))
character = ensureCharacterStats(dnfrepo.CloneCharacter(character), characterID)
var cost CostResult
inventoryDirty := false
switch operation {
case "create_account_cargo":
if metadataBool(account.Metadata, "account_cargo_created") || metadataInt(account.Metadata, "account_cargo_level") > 0 {
return ErrCargoExists
}
if character.Stats["gold"] < accountCargoCreateGoldCost {
return fmt.Errorf("%w: character=%d amount=%d", ErrGoldInsufficient, character.Stats["gold"], accountCargoCreateGoldCost)
}
character.Stats["gold"] -= accountCargoCreateGoldCost
cost = CostResult{Kind: CostGold, Amount: accountCargoCreateGoldCost}
account.Metadata["account_cargo_created"] = "true"
account.Metadata["account_cargo_level"] = strconv.FormatInt(accountCargoTiers[0], 10)
if strings.TrimSpace(account.Metadata["account_cargo_gold"]) == "" {
account.Metadata["account_cargo_gold"] = "0"
}
case "upgrade_account_cargo":
previous := metadataInt(account.Metadata, "account_cargo_level")
next, ok := nextCargoTier(previous)
if previous <= 0 {
return ErrCargoNotCreated
}
if !ok {
return ErrCargoMaxLevel
}
var inventory dnfrepo.InventoryRecord
if previous == 16 || previous == 24 {
inventory, found, err = inventoryRepo.Load(ctx, characterID)
if err != nil {
return err
}
if !found {
return ErrCargoCostMissing
}
inventory = dnfrepo.CloneInventory(inventory)
}
cost, inventoryDirty, err = applyCargoUpgradeCost(&character, &account, &inventory, previous)
if err != nil {
return err
}
if inventoryDirty {
inventory.CharacterID = characterID
inventory.UpdatedAt = time.Now().UTC()
if err := dnfrepo.SaveInventoryFields(ctx, inventoryRepo, inventory, dnfrepo.InventoryFieldSlots); err != nil {
return err
}
}
account.Metadata["account_cargo_created"] = "true"
account.Metadata["account_cargo_level"] = strconv.FormatInt(next, 10)
default:
return fmt.Errorf("operation %q invalid", operation)
}
now := time.Now().UTC()
character.UpdatedAt = now
account.UpdatedAt = now
if err := dnfrepo.SaveCharacterFields(ctx, characters, character, dnfrepo.CharacterFieldStats); err != nil {
return err
}
if err := accounts.Save(ctx, account); err != nil {
return err
}
result = PlanResult{AccountID: account.AccountID, CharacterID: characterID, Operation: operation, CharacterGold: character.Stats["gold"], CharacterCera: metadataInt(account.Metadata, "account_cera"), CargoGold: metadataInt(account.Metadata, "account_cargo_gold"), CargoLevel: metadataInt(account.Metadata, "account_cargo_level"), CargoCreated: metadataBool(account.Metadata, "account_cargo_created"), Cost: cost, Changed: true}
return nil
})
if err != nil {
return PlanResult{}, err
}
return result, nil
}
func (o *Owner) PlanMoney(ctx context.Context, cmd MoneyCommand) (PlanResult, error) {
if cmd.Amount <= 0 {
return PlanResult{}, fmt.Errorf("amount %d invalid", cmd.Amount)
}
account, err := o.loadAccount(ctx, cmd.AccountID)
if err != nil {
return PlanResult{}, err
}
characterID, character, err := o.loadCharacter(ctx, cmd.SelectedCharacterID)
if err != nil {
return PlanResult{}, err
}
character = dnfrepo.CloneCharacter(character)
characterGold := character.Stats["gold"]
cargoGold := metadataInt(account.Metadata, "account_cargo_gold")
switch cmd.Direction {
case MoneyDeposit:
if characterGold < cmd.Amount {
return PlanResult{}, fmt.Errorf("%w: character=%d amount=%d", ErrGoldInsufficient, characterGold, cmd.Amount)
}
case MoneyWithdraw:
if cargoGold < cmd.Amount {
return PlanResult{}, fmt.Errorf("%w: cargo=%d amount=%d", ErrGoldInsufficient, cargoGold, cmd.Amount)
}
default:
return PlanResult{}, fmt.Errorf("direction %q invalid", cmd.Direction)
}
return PlanResult{
AccountID: account.AccountID,
CharacterID: characterID,
Operation: string(cmd.Direction) + "_money",
Direction: cmd.Direction,
Amount: cmd.Amount,
CharacterGold: characterGold,
CharacterCera: metadataInt(account.Metadata, "account_cera"),
CargoGold: cargoGold,
CargoLevel: metadataInt(account.Metadata, "account_cargo_level"),
CargoCreated: metadataBool(account.Metadata, "account_cargo_created"),
}, nil
}
// ApplyMoney 同步写入角色金币和账号金库金币。
// 这是账号金库 305-308 链路的最小可靠 owner;客户端刷新包顺序未闭合前,handler 仍然阻断成功 ACK。
func (o *Owner) ApplyMoney(ctx context.Context, cmd MoneyCommand) (PlanResult, error) {
if cmd.Amount <= 0 {
return PlanResult{}, fmt.Errorf("amount %d invalid", cmd.Amount)
}
if o == nil || o.assets == nil {
return PlanResult{}, ErrOwnerUnavailable
}
accountID := strings.TrimSpace(cmd.AccountID)
if accountID == "" {
return PlanResult{}, ErrAccountRequired
}
if cmd.SelectedCharacterID == 0 {
return PlanResult{}, ErrCharacterRequired
}
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
var result PlanResult
err := o.assets.WithinRentalAssets(ctx, accountID, characterID, func(accounts dnfrepo.AccountRepository, characters dnfrepo.CharacterRepository, _ dnfrepo.InventoryRepository, _ dnfrepo.EquipmentRepository) error {
account, found, err := accounts.Load(ctx, accountID)
if err != nil {
return err
}
if !found {
return ErrAccountNotFound
}
character, found, err := characters.Load(ctx, characterID)
if err != nil {
return err
}
if !found {
return ErrCharacterNotFound
}
if owner := strings.TrimSpace(character.AccountID); owner != "" && owner != accountID {
return ErrAccountRequired
}
account = ensureAccountMetadata(dnfrepo.CloneAccount(account))
character = ensureCharacterStats(dnfrepo.CloneCharacter(character), characterID)
characterGold := character.Stats["gold"]
cargoGold := metadataInt(account.Metadata, "account_cargo_gold")
if !metadataBool(account.Metadata, "account_cargo_created") && metadataInt(account.Metadata, "account_cargo_level") <= 0 {
return ErrCargoNotCreated
}
switch cmd.Direction {
case MoneyDeposit:
if characterGold < cmd.Amount {
return fmt.Errorf("%w: character=%d amount=%d", ErrGoldInsufficient, characterGold, cmd.Amount)
}
characterGold -= cmd.Amount
cargoGold += cmd.Amount
case MoneyWithdraw:
if cargoGold < cmd.Amount {
return fmt.Errorf("%w: cargo=%d amount=%d", ErrGoldInsufficient, cargoGold, cmd.Amount)
}
characterGold += cmd.Amount
cargoGold -= cmd.Amount
default:
return fmt.Errorf("direction %q invalid", cmd.Direction)
}
now := time.Now().UTC()
character.Stats["gold"] = characterGold
character.UpdatedAt = now
account.Metadata["account_cargo_gold"] = strconv.FormatInt(cargoGold, 10)
account.Metadata["account_cargo_created"] = "true"
account.UpdatedAt = now
if err := dnfrepo.SaveCharacterFields(ctx, characters, character, dnfrepo.CharacterFieldStats); err != nil {
return err
}
if err := accounts.Save(ctx, account); err != nil {
return err
}
result = PlanResult{AccountID: account.AccountID, CharacterID: characterID, Operation: string(cmd.Direction) + "_money", Direction: cmd.Direction, Amount: cmd.Amount, CharacterGold: characterGold, CharacterCera: metadataInt(account.Metadata, "account_cera"), CargoGold: cargoGold, CargoLevel: metadataInt(account.Metadata, "account_cargo_level"), CargoCreated: metadataBool(account.Metadata, "account_cargo_created"), Changed: true}
return nil
})
if err != nil {
return PlanResult{}, err
}
return result, nil
}
func (o *Owner) loadAccount(ctx context.Context, accountID string) (dnfrepo.AccountRecord, error) {
if o == nil || o.accounts == nil {
return dnfrepo.AccountRecord{}, ErrOwnerUnavailable
}
accountID = strings.TrimSpace(accountID)
if accountID == "" {
return dnfrepo.AccountRecord{}, ErrAccountRequired
}
account, ok, err := o.accounts.Load(ctx, accountID)
if err != nil {
return dnfrepo.AccountRecord{}, err
}
if !ok {
return dnfrepo.AccountRecord{}, ErrAccountNotFound
}
return dnfrepo.CloneAccount(account), nil
}
func (o *Owner) loadCharacter(ctx context.Context, selectedCharacterID uint16) (string, dnfrepo.CharacterRecord, error) {
if o == nil || o.characters == nil {
return "", dnfrepo.CharacterRecord{}, ErrOwnerUnavailable
}
if selectedCharacterID == 0 {
return "", dnfrepo.CharacterRecord{}, ErrCharacterRequired
}
characterID := strconv.FormatUint(uint64(selectedCharacterID), 10)
character, ok, err := o.characters.Load(ctx, characterID)
if err != nil {
return "", dnfrepo.CharacterRecord{}, err
}
if !ok {
return "", dnfrepo.CharacterRecord{}, ErrCharacterNotFound
}
return characterID, dnfrepo.CloneCharacter(character), nil
}
func ensureAccountMetadata(account dnfrepo.AccountRecord) dnfrepo.AccountRecord {
if account.Metadata == nil {
account.Metadata = make(map[string]string, 4)
}
return account
}
func ensureCharacterStats(character dnfrepo.CharacterRecord, characterID string) dnfrepo.CharacterRecord {
character.CharacterID = characterID
if character.Stats == nil {
character.Stats = make(map[string]int64, 1)
}
return character
}
func nextCargoTier(previous int64) (int64, bool) {
for index, tier := range accountCargoTiers {
if tier == previous && index+1 < len(accountCargoTiers) {
return accountCargoTiers[index+1], true
}
}
return previous, false
}
// NextAccountCargoTier is shared by the normal account-cargo command and the
// PVF-resolved Cera-shop upgrade tool. Keeping one progression prevents the
// shop from silently skipping a normal client-visible tier.
func NextAccountCargoTier(previous int64) (int64, bool) {
return nextCargoTier(previous)
}
func applyCargoUpgradeCost(character *dnfrepo.CharacterRecord, account *dnfrepo.AccountRecord, inventory *dnfrepo.InventoryRecord, previousTier int64) (CostResult, bool, error) {
switch previousTier {
case 1:
const cost int64 = 2000000
if character.Stats["gold"] < cost {
return CostResult{}, false, fmt.Errorf("%w: character=%d amount=%d", ErrGoldInsufficient, character.Stats["gold"], cost)
}
character.Stats["gold"] -= cost
return CostResult{Kind: CostGold, Amount: cost}, false, nil
case 8, 32, 40, 48, 56:
cost := map[int64]int64{8: 2000, 32: 2000, 40: 2500, 48: 3000, 56: 5000}[previousTier]
balance := metadataInt(account.Metadata, "account_cera")
if balance < cost {
return CostResult{}, false, fmt.Errorf("%w: cera=%d amount=%d", ErrGoldInsufficient, balance, cost)
}
account.Metadata["account_cera"] = strconv.FormatInt(balance-cost, 10)
return CostResult{Kind: CostCera, Amount: cost}, false, nil
case 16, 24:
if inventory == nil || inventory.Slots == nil {
return CostResult{}, false, fmt.Errorf("%w: void magic stone inventory missing for tier=%d", ErrCargoCostMissing, previousTier)
}
remaining := accountCargoVoidMagicStoneCost
keys := make([]string, 0)
for key, stack := range inventory.Slots {
if strings.HasPrefix(key, "0:") && stack.ItemID == accountCargoVoidMagicStoneItemID && stack.Count > 0 {
keys = append(keys, key)
}
}
sort.Strings(keys)
for _, key := range keys {
stack := inventory.Slots[key]
if stack.Count >= remaining {
remaining = 0
break
}
remaining -= stack.Count
}
if remaining > 0 {
return CostResult{}, false, fmt.Errorf("%w: item=%d need=%d", ErrCargoMaterialInsufficient, accountCargoVoidMagicStoneItemID, accountCargoVoidMagicStoneCost)
}
remaining = accountCargoVoidMagicStoneCost
for _, key := range keys {
stack := inventory.Slots[key]
consume := stack.Count
if consume > remaining {
consume = remaining
}
stack.Count -= consume
remaining -= consume
if stack.Count == 0 {
delete(inventory.Slots, key)
} else {
inventory.Slots[key] = stack
}
if remaining == 0 {
break
}
}
return CostResult{Kind: CostMaterial, Amount: accountCargoVoidMagicStoneCost}, true, nil
default:
// 64 格以上只有商城工具,金库窗口按钮没有可扣的费用。这里必须失败关闭:
// 返回零费用会让 op306 白送一档。
return CostResult{}, false, fmt.Errorf("%w: tier=%d", ErrCargoUpgradeToolRequired, previousTier)
}
}
func metadataInt(metadata map[string]string, key string) int64 {
raw := strings.TrimSpace(metadata[key])
if raw == "" {
return 0
}
value, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return 0
}
return value
}
func metadataBool(metadata map[string]string, key string) bool {
switch strings.ToLower(strings.TrimSpace(metadata[key])) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
@@ -0,0 +1,448 @@
package cargo
import (
"context"
"errors"
"testing"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestOwnerPlanAccountReadsCargoMetadata(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "acc-1",
Metadata: map[string]string{
"account_cargo_gold": "1200",
"account_cargo_level": "3",
"account_cargo_created": "1",
},
}); err != nil {
t.Fatalf("save account: %v", err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner() error = %v", err)
}
got, err := owner.PlanAccount(ctx, "upgrade_account_cargo", AccountCommand{AccountID: " acc-1 "})
if err != nil {
t.Fatalf("PlanAccount() error = %v", err)
}
if got.AccountID != "acc-1" || got.CargoGold != 1200 || got.CargoLevel != 3 || !got.CargoCreated {
t.Fatalf("result = %+v", got)
}
}
func TestOwnerPlanMoneyDepositChecksCharacterGold(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "acc-2",
Metadata: map[string]string{"account_cargo_gold": "50"},
}); err != nil {
t.Fatalf("save account: %v", err)
}
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "77",
AccountID: "acc-2",
Stats: map[string]int64{"gold": 500},
}); err != nil {
t.Fatalf("save character: %v", err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner() error = %v", err)
}
got, err := owner.PlanMoney(ctx, MoneyCommand{
AccountID: "acc-2",
SelectedCharacterID: 77,
Direction: MoneyDeposit,
Amount: 300,
})
if err != nil {
t.Fatalf("PlanMoney() error = %v", err)
}
if got.CharacterGold != 500 || got.CargoGold != 50 || got.Amount != 300 || got.Direction != MoneyDeposit {
t.Fatalf("result = %+v", got)
}
}
func TestOwnerPlanMoneyWithdrawChecksCargoGold(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "acc-3",
Metadata: map[string]string{"account_cargo_gold": "900"},
}); err != nil {
t.Fatalf("save account: %v", err)
}
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "78",
AccountID: "acc-3",
Stats: map[string]int64{"gold": 20},
}); err != nil {
t.Fatalf("save character: %v", err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner() error = %v", err)
}
got, err := owner.PlanMoney(ctx, MoneyCommand{
AccountID: "acc-3",
SelectedCharacterID: 78,
Direction: MoneyWithdraw,
Amount: 700,
})
if err != nil {
t.Fatalf("PlanMoney() error = %v", err)
}
if got.CharacterGold != 20 || got.CargoGold != 900 || got.Amount != 700 || got.Direction != MoneyWithdraw {
t.Fatalf("result = %+v", got)
}
}
func TestOwnerPlanMoneyRejectsInsufficientGold(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "acc-4",
Metadata: map[string]string{"account_cargo_gold": "5"},
}); err != nil {
t.Fatalf("save account: %v", err)
}
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "79",
AccountID: "acc-4",
Stats: map[string]int64{"gold": 10},
}); err != nil {
t.Fatalf("save character: %v", err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner() error = %v", err)
}
_, err = owner.PlanMoney(ctx, MoneyCommand{
AccountID: "acc-4",
SelectedCharacterID: 79,
Direction: MoneyWithdraw,
Amount: 6,
})
if !errors.Is(err, ErrGoldInsufficient) {
t.Fatalf("PlanMoney() error = %v, want ErrGoldInsufficient", err)
}
}
func TestOwnerApplyCreateCargoChargesGold(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{AccountID: "acc-5"}); err != nil {
t.Fatalf("save account: %v", err)
}
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "80",
AccountID: "acc-5",
Stats: map[string]int64{"gold": 500000},
}); err != nil {
t.Fatalf("save character: %v", err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner() error = %v", err)
}
got, err := owner.ApplyAccount(ctx, "create_account_cargo", AccountCommand{
AccountID: "acc-5",
SelectedCharacterID: 80,
})
if err != nil {
t.Fatalf("ApplyAccount() error = %v", err)
}
if !got.Changed || got.CharacterGold != 400000 || got.CargoLevel != 1 || !got.CargoCreated {
t.Fatalf("result = %+v", got)
}
account, ok, err := repos.Account.Load(ctx, "acc-5")
if err != nil || !ok {
t.Fatalf("load account ok=%t err=%v", ok, err)
}
if account.Metadata["account_cargo_level"] != "1" || account.Metadata["account_cargo_created"] != "true" {
t.Fatalf("account metadata = %+v", account.Metadata)
}
character, ok, err := repos.Character.Load(ctx, "80")
if err != nil || !ok {
t.Fatalf("load character ok=%t err=%v", ok, err)
}
if got := character.Stats["gold"]; got != 400000 {
t.Fatalf("character gold = %d, want 400000", got)
}
}
func TestOwnerApplyAccountUpgradeDebitsAccountSharedCera(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "acc-cera",
Metadata: map[string]string{
"account_cargo_created": "true",
"account_cargo_level": "8",
"account_cargo_gold": "0",
"account_cera": "3000",
},
}); err != nil {
t.Fatalf("save account: %v", err)
}
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "87", AccountID: "acc-cera", Stats: map[string]int64{"gold": 1}}); err != nil {
t.Fatalf("save character: %v", err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner() error = %v", err)
}
got, err := owner.ApplyAccount(ctx, "upgrade_account_cargo", AccountCommand{AccountID: "acc-cera", SelectedCharacterID: 87})
if err != nil {
t.Fatalf("ApplyAccount() error = %v", err)
}
if !got.Changed || got.Cost.Kind != CostCera || got.Cost.Amount != 2000 || got.CharacterCera != 1000 || got.CargoLevel != 16 {
t.Fatalf("result = %+v", got)
}
account, ok, err := repos.Account.Load(ctx, "acc-cera")
if err != nil || !ok {
t.Fatalf("load account ok=%t err=%v", ok, err)
}
if account.Metadata["account_cera"] != "1000" || account.Metadata["account_cargo_level"] != "16" {
t.Fatalf("account metadata = %+v", account.Metadata)
}
}
func TestOwnerApplyAccountUpgradeRejectsInsufficientAccountCera(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "acc-cera-poor",
Metadata: map[string]string{
"account_cargo_created": "true",
"account_cargo_level": "8",
"account_cargo_gold": "0",
"account_cera": "100",
},
}); err != nil {
t.Fatalf("save account: %v", err)
}
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "88", AccountID: "acc-cera-poor", Stats: map[string]int64{"gold": 1}}); err != nil {
t.Fatalf("save character: %v", err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner() error = %v", err)
}
if _, err := owner.ApplyAccount(ctx, "upgrade_account_cargo", AccountCommand{AccountID: "acc-cera-poor", SelectedCharacterID: 88}); !errors.Is(err, ErrGoldInsufficient) {
t.Fatalf("ApplyAccount() error = %v, want ErrGoldInsufficient", err)
}
account, _, _ := repos.Account.Load(ctx, "acc-cera-poor")
if account.Metadata["account_cera"] != "100" || account.Metadata["account_cargo_level"] != "8" {
t.Fatalf("insufficient-cera attempt mutated account: %+v", account.Metadata)
}
}
func TestOwnerApplyMoneyDepositPersistsBothSides(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "acc-6",
Metadata: map[string]string{
"account_cargo_gold": "50",
"account_cargo_level": "1",
"account_cargo_created": "true",
},
}); err != nil {
t.Fatalf("save account: %v", err)
}
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "81",
AccountID: "acc-6",
Stats: map[string]int64{"gold": 500},
}); err != nil {
t.Fatalf("save character: %v", err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner() error = %v", err)
}
got, err := owner.ApplyMoney(ctx, MoneyCommand{
AccountID: "acc-6",
SelectedCharacterID: 81,
Direction: MoneyDeposit,
Amount: 300,
})
if err != nil {
t.Fatalf("ApplyMoney() error = %v", err)
}
if !got.Changed || got.CharacterGold != 200 || got.CargoGold != 350 {
t.Fatalf("result = %+v", got)
}
account, ok, err := repos.Account.Load(ctx, "acc-6")
if err != nil || !ok {
t.Fatalf("load account ok=%t err=%v", ok, err)
}
if got := account.Metadata["account_cargo_gold"]; got != "350" {
t.Fatalf("cargo gold = %q, want 350", got)
}
character, ok, err := repos.Character.Load(ctx, "81")
if err != nil || !ok {
t.Fatalf("load character ok=%t err=%v", ok, err)
}
if got := character.Stats["gold"]; got != 200 {
t.Fatalf("character gold = %d, want 200", got)
}
}
func TestOwnerApplyAccountUpgradeConsumesVoidMagicStoneForMiddleTiers(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "acc-void",
Metadata: map[string]string{
"account_cargo_created": "true",
"account_cargo_level": "16",
"account_cargo_gold": "0",
},
}); err != nil {
t.Fatal(err)
}
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "86", AccountID: "acc-void", Stats: map[string]int64{"gold": 1, "cera": 1}}); err != nil {
t.Fatal(err)
}
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "86", Slots: map[string]dnfrepo.ItemStack{
"0:10": {ItemID: accountCargoVoidMagicStoneItemID, Count: 200},
"0:11": {ItemID: accountCargoVoidMagicStoneItemID, Count: 100},
}}); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
got, err := owner.ApplyAccount(ctx, "upgrade_account_cargo", AccountCommand{AccountID: "acc-void", SelectedCharacterID: 86})
if err != nil {
t.Fatalf("upgrade account cargo: %v", err)
}
if !got.Changed || got.CargoLevel != 24 || got.Cost.Kind != CostMaterial || got.Cost.Amount != 250 {
t.Fatalf("upgrade result=%+v", got)
}
items, found, err := repos.Inventory.Load(ctx, "86")
if err != nil || !found {
t.Fatalf("load inventory found=%t err=%v", found, err)
}
if _, exists := items.Slots["0:10"]; exists {
t.Fatalf("first material stack not consumed: %+v", items.Slots)
}
if got := items.Slots["0:11"].Count; got != 50 {
t.Fatalf("remaining void magic stones=%d want=50", got)
}
}
func TestOwnerApplyMoneyWithdrawPersistsBothSides(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "acc-7",
Metadata: map[string]string{
"account_cargo_gold": "900",
"account_cargo_level": "1",
"account_cargo_created": "true",
},
}); err != nil {
t.Fatalf("save account: %v", err)
}
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "82",
AccountID: "acc-7",
Stats: map[string]int64{"gold": 20},
}); err != nil {
t.Fatalf("save character: %v", err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner() error = %v", err)
}
got, err := owner.ApplyMoney(ctx, MoneyCommand{
AccountID: "acc-7",
SelectedCharacterID: 82,
Direction: MoneyWithdraw,
Amount: 700,
})
if err != nil {
t.Fatalf("ApplyMoney() error = %v", err)
}
if !got.Changed || got.CharacterGold != 720 || got.CargoGold != 200 {
t.Fatalf("result = %+v", got)
}
}
// TestNextAccountCargoTierCoversEveryCeraShopTool 锁住完整阶梯。2026-08-25 现网
// 抓包证明客户端按顺序消耗 12 个点券工具,容量 64 时请求 tool6(64->72);阶梯
// 只写到 64 时那次购买被拒,客户端显示"物品栏空间不足"。
func TestNextAccountCargoTierCoversEveryCeraShopTool(t *testing.T) {
want := []int64{1, 8, 16, 24, 32, 40, 48, 56, 64, 72, 80, 88, 96, 104, 112, 120}
tier := want[0]
for _, expected := range want[1:] {
next, ok := NextAccountCargoTier(tier)
if !ok || next != expected {
t.Fatalf("NextAccountCargoTier(%d) = %d, %t; want %d, true", tier, next, ok, expected)
}
tier = next
}
if next, ok := NextAccountCargoTier(tier); ok {
t.Fatalf("NextAccountCargoTier(%d) = %d, true; want max level", tier, next)
}
if next, ok := NextAccountCargoTier(5); ok {
t.Fatalf("NextAccountCargoTier(5) = %d, true; want an unknown tier to fail closed", next)
}
}
// TestOwnerApplyAccountUpgradeRequiresToolAboveMaterialTiers 保证新接上的高档位
// 不会被金库窗口按钮(op306)白送——那些档只有商城工具能付费。
func TestOwnerApplyAccountUpgradeRequiresToolAboveMaterialTiers(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "acc-tool",
Metadata: map[string]string{
"account_cargo_created": "true",
"account_cargo_level": "64",
"account_cargo_gold": "0",
"account_cera": "9000000",
},
}); err != nil {
t.Fatalf("save account: %v", err)
}
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "89", AccountID: "acc-tool", Stats: map[string]int64{"gold": 900000000}}); err != nil {
t.Fatalf("save character: %v", err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner() error = %v", err)
}
if _, err := owner.ApplyAccount(ctx, "upgrade_account_cargo", AccountCommand{AccountID: "acc-tool", SelectedCharacterID: 89}); !errors.Is(err, ErrCargoUpgradeToolRequired) {
t.Fatalf("ApplyAccount() error = %v, want ErrCargoUpgradeToolRequired", err)
}
account, ok, err := repos.Account.Load(ctx, "acc-tool")
if err != nil || !ok {
t.Fatalf("load account ok=%t err=%v", ok, err)
}
if account.Metadata["account_cargo_level"] != "64" || account.Metadata["account_cera"] != "9000000" {
t.Fatalf("blocked op306 upgrade mutated account: %+v", account.Metadata)
}
}
@@ -0,0 +1,29 @@
// 本文件解析账号仓库类 C2S 请求体。
// 解析结果只用于生成后续 owner 命令计划,不直接修改金币或仓库状态。
package cargo
import (
"encoding/binary"
"fmt"
)
// GoldRequest 描述仓库存取金币请求。
type GoldRequest struct {
Amount int32
}
// DecodeGoldRequest 解析存入/取出金币请求,C# 证据为 body[0:4] little-endian int32。
func DecodeGoldRequest(body []byte) (GoldRequest, error) {
if len(body) < 4 {
return GoldRequest{}, fmt.Errorf("body too short: got %d want >= 4", len(body))
}
req := GoldRequest{Amount: int32(binary.LittleEndian.Uint32(body))}
if req.Amount <= 0 {
return GoldRequest{}, fmt.Errorf("amount %d invalid", req.Amount)
}
return req, nil
}
func (r GoldRequest) String() string {
return fmt.Sprintf("amount=%d", r.Amount)
}
@@ -0,0 +1,274 @@
// Package carrygold owns the gold carry-cap rule: the PVF base limit a
// character's level grants, the durable upgrade stage the player has bought on
// top of it, and the credit helper every gold faucet uses so the cap actually
// bites when gold is gained instead of only being displayed.
//
// The base limit is real PVF data, not a constant: `etc/(r)goldlimitbylevel.etc`
// carries `[gold limit from level]` as level/limit pairs, rising from 0 at
// level 0 to 400,000,000 from level 60 on. The upgrade adds one 亿 per stage on
// top of that, which is why the client's confirm dialog offers 4亿 -> 5亿 for a
// level-capped character.
//
// The stage itself is decoded from the current client, which is authoritative
// for this feature because the release-side server had no handler for it at
// all — the op978 request was received, dropped, and the confirm button
// therefore did nothing.
//
// Request, legacy cmd1/type978 built by sub_1AEBF10:
//
// mov al, [0x052B5C04] ; the client's own cached stage
// inc al
// push 978
// ... ; put_header
// mov [ebp-7], al ; the only field the builder writes
// push 0x0E; push [ebp-0x14]; put_binary
//
// The blob is 14 bytes copied straight off the stack and only its **last**
// byte is initialized, so bytes 0..12 are uninitialized stack residue and must
// be ignored.
//
// Response, class1/op978 read by sub_1D23D60: the ordinary success envelope
// byte must be non-zero, then u8 stage and u32 result. A non-zero result takes
// the client's failure branch; zero stores the stage into 0x052B5C04 and
// formats client string 0x1194D with stage+4.
//
// Bounds are the client's own: sub_1AEBFF0 and three other sites refuse to
// offer an upgrade once the stage reaches 4, and the stage is displayed as
// stage+4, so the five stages read 4..8 (亿) in game.
package carrygold
import (
"context"
"errors"
"fmt"
"sort"
"sync/atomic"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const (
// StageStat is the durable character stat holding the purchased stage.
StageStat = "carry_gold_stage"
// MaxStage is the last stage the client will ever offer.
MaxStage = byte(4)
// StageCost is the flat price the client promises the player. Its confirm
// dialog reads "扩充时将消耗500万金币" while raising the cap by one 亿, and
// that number is baked into the client string: it is absent from the
// request, from the runtime Script.pvf, and from every code immediate in
// DNF.exe, so nothing per-stage can be derived and the charge is flat.
StageCost = int64(5_000_000)
// Unit is one 亿, the granularity one purchased stage adds.
Unit = int64(100_000_000)
// DisplayedBase is the cap the client's own string formats at stage 0. It
// is the level-capped case, which is the only one the dialog can show.
DisplayedBase = 4
// LevelLimitPath is the PVF table holding the per-level base cap.
LevelLimitPath = "etc/(r)goldlimitbylevel.etc"
// LevelLimitKey is the section inside that table.
LevelLimitKey = "gold limit from level"
)
var (
ErrSourceRequired = errors.New("carry gold pvf source is required")
ErrLevelLimitTable = errors.New("carry gold level limit table is malformed")
)
// Source is the PVF text reader the loader needs.
type Source interface {
ReadText(relativePath string) (string, error)
}
type levelLimitEntry struct {
level int
limit int64
}
// LevelLimits is the parsed `[gold limit from level]` table. It is immutable
// once loaded.
type LevelLimits struct {
entries []levelLimitEntry
}
// LoadLevelLimits parses the real PVF table. It never falls back to a guessed
// table: a missing or malformed file is an error the caller has to surface.
func LoadLevelLimits(ctx context.Context, source Source) (*LevelLimits, error) {
if source == nil {
return nil, ErrSourceRequired
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return nil, err
}
text, err := source.ReadText(LevelLimitPath)
if err != nil {
return nil, fmt.Errorf("read %s: %w", LevelLimitPath, err)
}
document, err := dnfpvf.Parse(LevelLimitPath, text)
if err != nil {
return nil, fmt.Errorf("parse %s: %w", LevelLimitPath, err)
}
return parseLevelLimits(document.Ints(LevelLimitKey))
}
func parseLevelLimits(values []int64) (*LevelLimits, error) {
if len(values) < 2 || len(values)%2 != 0 {
return nil, fmt.Errorf("%w: %s has %d values", ErrLevelLimitTable, LevelLimitKey, len(values))
}
entries := make([]levelLimitEntry, 0, len(values)/2)
for index := 0; index+1 < len(values); index += 2 {
level := values[index]
limit := values[index+1]
if level < 0 || limit < 0 {
return nil, fmt.Errorf("%w: level=%d limit=%d", ErrLevelLimitTable, level, limit)
}
entries = append(entries, levelLimitEntry{level: int(level), limit: limit})
}
sort.Slice(entries, func(i, j int) bool { return entries[i].level < entries[j].level })
return &LevelLimits{entries: entries}, nil
}
// Limit is the base cap a character of this level may hold, before any bought
// stage. Levels between two rows keep the lower row's value, and a level above
// the last row keeps the last row's value, which is how the client's own table
// lookup behaves.
func (l *LevelLimits) Limit(level int) int64 {
if l == nil || len(l.entries) == 0 {
return 0
}
if level < l.entries[0].level {
return l.entries[0].limit
}
index := sort.Search(len(l.entries), func(i int) bool { return l.entries[i].level > level })
return l.entries[index-1].limit
}
// Len reports how many level rows were parsed. It exists so the preload can
// log what it installed.
func (l *LevelLimits) Len() int {
if l == nil {
return 0
}
return len(l.entries)
}
// installed holds the process-wide table. Static game data is read by every
// gold faucet in every module, so it is installed once at startup instead of
// being threaded through a dozen commands. A nil table means "not loaded", and
// then no cap is applied at all — the rule refuses to invent a limit the PVF
// did not supply.
var installed atomic.Pointer[LevelLimits]
// Install publishes the table for every faucet. The dnfbridge PVF preload owns
// the single call; passing nil clears it, which only tests do.
func Install(limits *LevelLimits) {
installed.Store(limits)
}
// Installed returns the published table, or nil when none has been loaded.
func Installed() *LevelLimits {
return installed.Load()
}
// Stage reads the durable stage out of a character stat map, clamped to what
// the client accepts. A missing or non-positive stat is stage zero, which is
// the un-upgraded default.
func Stage(stats map[string]int64) byte {
if stats == nil {
return 0
}
return StageFromValue(stats[StageStat])
}
// StageFromValue clamps a raw persisted value into the client's stage range.
func StageFromValue(value int64) byte {
if value <= 0 {
return 0
}
if value >= int64(MaxStage) {
return MaxStage
}
return byte(value)
}
// DisplayedLimit reproduces the client's own string, which formats stage+4 (亿)
// regardless of level. It is a log/UI value, never the rule.
func DisplayedLimit(stage byte) int {
return DisplayedBase + int(StageFromValue(int64(stage)))
}
// StageBonus is what one purchased stage adds on top of the PVF base limit.
func StageBonus(stage byte) int64 {
return int64(StageFromValue(int64(stage))) * Unit
}
// LimitForCharacter is the character's effective carry cap: the PVF base limit
// for its level plus the bought stages. It is zero when no PVF table has been
// installed, which every helper here reads as "uncapped".
func LimitForCharacter(character dnfrepo.CharacterRecord) int64 {
table := Installed()
if table == nil {
return 0
}
base := table.Limit(character.Level)
if base <= 0 {
return 0
}
return base + StageBonus(Stage(character.Stats))
}
// CreditBalance applies amount to balance without ever exceeding limit, and
// reports how much had to be discarded. A limit of zero means uncapped.
//
// It deliberately never *reduces* an existing balance. Release-side gold was
// never capped, so a character can already hold more than its level and stage
// allow; clamping such a balance downwards would delete gold the player owns.
// A balance already at or above the limit simply gains nothing.
func CreditBalance(balance int64, amount int64, limit int64) (after int64, discarded int64) {
if amount <= 0 || limit <= 0 {
return balance + amount, 0
}
if balance >= limit {
return balance, amount
}
room := limit - balance
if amount <= room {
return balance + amount, 0
}
return limit, amount - room
}
// Credit is CreditBalance against the character's own level and stage.
func Credit(character dnfrepo.CharacterRecord, amount int64) (after int64, discarded int64) {
var balance int64
if character.Stats != nil {
balance = character.Stats["gold"]
}
return CreditBalance(balance, amount, LimitForCharacter(character))
}
// Room is how much more gold the character may still gain. It is zero once the
// balance has reached the cap, and math.MaxInt64 when no cap is installed.
func Room(character dnfrepo.CharacterRecord) int64 {
limit := LimitForCharacter(character)
if limit <= 0 {
return int64(^uint64(0) >> 1)
}
var balance int64
if character.Stats != nil {
balance = character.Stats["gold"]
}
if balance >= limit {
return 0
}
return limit - balance
}
@@ -0,0 +1,243 @@
package carrygold
import (
"context"
"errors"
"testing"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
// realLevelLimitRows is the shape of the runtime table
// `etc/(r)goldlimitbylevel.etc` carries, trimmed to the rows the rules care
// about: the low-level ramp, the level-60 plateau, and the level-90 cap.
var realLevelLimitRows = []int64{
0, 0,
1, 100_000_000,
30, 100_000_000,
60, 400_000_000,
99, 400_000_000,
}
func installTestLevelLimits(t *testing.T, values []int64) {
t.Helper()
limits, err := parseLevelLimits(values)
if err != nil {
t.Fatal(err)
}
previous := Installed()
Install(limits)
t.Cleanup(func() { Install(previous) })
}
func TestStageClampsToTheClientBound(t *testing.T) {
if got := Stage(nil); got != 0 {
t.Fatalf("nil stats stage=%d", got)
}
if got := Stage(map[string]int64{}); got != 0 {
t.Fatalf("missing stat stage=%d", got)
}
if got := Stage(map[string]int64{StageStat: -3}); got != 0 {
t.Fatalf("negative stat stage=%d", got)
}
if got := Stage(map[string]int64{StageStat: 99}); got != MaxStage {
t.Fatalf("clamped stage=%d want %d", got, MaxStage)
}
for stage := byte(0); stage <= MaxStage; stage++ {
if got := DisplayedLimit(stage); got != DisplayedBase+int(stage) {
t.Fatalf("stage=%d displayed=%d", stage, got)
}
if got := StageBonus(stage); got != int64(stage)*Unit {
t.Fatalf("stage=%d bonus=%d", stage, got)
}
}
}
func TestLevelLimitsFollowThePVFTable(t *testing.T) {
limits, err := parseLevelLimits(realLevelLimitRows)
if err != nil {
t.Fatal(err)
}
// A level between two rows keeps the lower row's value, and a level above
// the last row keeps the last row's value.
for level, want := range map[int]int64{
0: 0, 1: 100_000_000, 29: 100_000_000, 30: 100_000_000,
59: 100_000_000, 60: 400_000_000, 90: 400_000_000, 200: 400_000_000,
} {
if got := limits.Limit(level); got != want {
t.Fatalf("level=%d limit=%d want %d", level, got, want)
}
}
if limits.Len() != len(realLevelLimitRows)/2 {
t.Fatalf("rows=%d", limits.Len())
}
if _, err := parseLevelLimits([]int64{1}); !errors.Is(err, ErrLevelLimitTable) {
t.Fatalf("odd table err=%v", err)
}
if _, err := LoadLevelLimits(context.Background(), nil); !errors.Is(err, ErrSourceRequired) {
t.Fatalf("nil source err=%v", err)
}
}
func TestLimitForCharacterAddsTheBoughtStagesToThePVFBase(t *testing.T) {
installTestLevelLimits(t, realLevelLimitRows)
capped := dnfrepo.CharacterRecord{Level: 90, Stats: map[string]int64{}}
if got := LimitForCharacter(capped); got != 400_000_000 {
t.Fatalf("level 90 stage 0 limit=%d", got)
}
capped.Stats[StageStat] = int64(MaxStage)
// Four bought stages read as 8亿 in game, which is what the client's own
// stage+4 string promises at the level cap.
if got := LimitForCharacter(capped); got != 800_000_000 {
t.Fatalf("level 90 stage 4 limit=%d", got)
}
// A low-level character gets the PVF base for its level, not the 4亿 the
// dialog shows, plus the same per-stage 亿.
low := dnfrepo.CharacterRecord{Level: 30, Stats: map[string]int64{StageStat: 1}}
if got := LimitForCharacter(low); got != 200_000_000 {
t.Fatalf("level 30 stage 1 limit=%d", got)
}
// Level 0 has no PVF allowance at all, which must read as uncapped rather
// than as a zero cap that would swallow every gold gain.
if got := LimitForCharacter(dnfrepo.CharacterRecord{Level: 0}); got != 0 {
t.Fatalf("level 0 limit=%d", got)
}
}
func TestNoInstalledTableLeavesEveryFaucetUncapped(t *testing.T) {
previous := Installed()
Install(nil)
t.Cleanup(func() { Install(previous) })
character := dnfrepo.CharacterRecord{Level: 90, Stats: map[string]int64{"gold": 900_000_000}}
if got := LimitForCharacter(character); got != 0 {
t.Fatalf("uninstalled limit=%d", got)
}
after, discarded := Credit(character, 1_000)
if after != 900_001_000 || discarded != 0 {
t.Fatalf("uninstalled credit after=%d discarded=%d", after, discarded)
}
}
func TestCreditStopsAtTheCapAndNeverLowersAnExistingBalance(t *testing.T) {
installTestLevelLimits(t, realLevelLimitRows)
character := dnfrepo.CharacterRecord{Level: 90, Stats: map[string]int64{"gold": 399_000_000}}
after, discarded := Credit(character, 5_000_000)
if after != 400_000_000 || discarded != 4_000_000 {
t.Fatalf("credit at cap after=%d discarded=%d", after, discarded)
}
// One purchased stage buys exactly one 亿 more room.
character.Stats[StageStat] = 1
after, discarded = Credit(character, 5_000_000)
if after != 404_000_000 || discarded != 0 {
t.Fatalf("credit after upgrade after=%d discarded=%d", after, discarded)
}
// Release-side gold was never capped, so an existing balance above the cap
// must be left alone rather than clamped down and destroyed.
over := dnfrepo.CharacterRecord{Level: 90, Stats: map[string]int64{"gold": 900_000_000}}
after, discarded = Credit(over, 1_000)
if after != 900_000_000 || discarded != 1_000 {
t.Fatalf("credit above cap after=%d discarded=%d", after, discarded)
}
if got := Room(over); got != 0 {
t.Fatalf("room above cap=%d", got)
}
if got := Room(dnfrepo.CharacterRecord{Level: 90, Stats: map[string]int64{"gold": 100_000_000}}); got != 300_000_000 {
t.Fatalf("room=%d", got)
}
}
func newUpgradeOwner(t *testing.T, gold int64, stage int64) (*Owner, dnfrepo.Group) {
t.Helper()
repositories := dnfrepomemory.NewMemoryGroup()
stats := map[string]int64{"gold": gold}
if stage > 0 {
stats[StageStat] = stage
}
if err := repositories.Character.Save(context.Background(), dnfrepo.CharacterRecord{
CharacterID: "17", AccountID: "account-1", Level: 90, Stats: stats,
}); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
return owner, repositories
}
func TestOwnerUpgradeChargesAndPersistsTheNextStage(t *testing.T) {
installTestLevelLimits(t, realLevelLimitRows)
owner, repositories := newUpgradeOwner(t, 250_000_000, 0)
result, err := owner.Upgrade(context.Background(), Command{
AccountID: "account-1", SelectedCharacterID: 17, RequestedStage: 1,
})
if err != nil {
t.Fatal(err)
}
if !result.Upgraded || result.Stage != 1 || result.PreviousStage != 0 ||
result.GoldAfter != 250_000_000-StageCost || result.Limit != 500_000_000 {
t.Fatalf("upgrade result=%+v", result)
}
stored, _, _ := repositories.Character.Load(context.Background(), "17")
if stored.Stats[StageStat] != 1 || stored.Stats["gold"] != 250_000_000-StageCost {
t.Fatalf("stored stats=%+v", stored.Stats)
}
// The purchased stage has to move the limit gold gains are measured
// against, otherwise the upgrade is a number nobody reads.
if got := LimitForCharacter(stored); got != 500_000_000 {
t.Fatalf("stored limit=%d", got)
}
}
func TestOwnerUpgradeRefusalsLeaveTheCharacterUntouched(t *testing.T) {
installTestLevelLimits(t, realLevelLimitRows)
for name, testCase := range map[string]struct {
gold int64
stage int64
requested byte
wantErr error
}{
"out of sequence": {gold: 900_000_000, stage: 1, requested: 3, wantErr: ErrStageOutOfSequence},
"exhausted": {gold: 900_000_000, stage: int64(MaxStage), requested: MaxStage + 1, wantErr: ErrStageExhausted},
"short on gold": {gold: StageCost - 1, stage: 0, requested: 1, wantErr: ErrGoldInsufficient},
} {
t.Run(name, func(t *testing.T) {
owner, repositories := newUpgradeOwner(t, testCase.gold, testCase.stage)
result, err := owner.Upgrade(context.Background(), Command{
AccountID: "account-1", SelectedCharacterID: 17, RequestedStage: testCase.requested,
})
if !errors.Is(err, testCase.wantErr) {
t.Fatalf("err=%v want %v", err, testCase.wantErr)
}
// The refusal still has to carry the durable stage so the client can
// be told what it really owns.
if result.Upgraded || result.Stage != StageFromValue(testCase.stage) {
t.Fatalf("refused result=%+v", result)
}
stored, _, _ := repositories.Character.Load(context.Background(), "17")
if stored.Stats["gold"] != testCase.gold || stored.Stats[StageStat] != testCase.stage {
t.Fatalf("refused upgrade mutated stats=%+v", stored.Stats)
}
})
}
}
func TestOwnerRejectsAForeignAccountAndAMissingCharacter(t *testing.T) {
owner, _ := newUpgradeOwner(t, 250_000_000, 0)
if _, err := owner.Upgrade(context.Background(), Command{
AccountID: "account-2", SelectedCharacterID: 17, RequestedStage: 1,
}); !errors.Is(err, ErrAccountMismatch) {
t.Fatalf("account mismatch err=%v", err)
}
if _, err := owner.Upgrade(context.Background(), Command{
AccountID: "account-1", SelectedCharacterID: 99, RequestedStage: 1,
}); !errors.Is(err, ErrCharacterNotFound) {
t.Fatalf("missing character err=%v", err)
}
if _, err := owner.Load(context.Background(), 0); !errors.Is(err, ErrCharacterRequired) {
t.Fatalf("missing selection err=%v", err)
}
}
@@ -0,0 +1,164 @@
package carrygold
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
var (
ErrOwnerUnavailable = errors.New("carry gold owner unavailable")
ErrCharacterRequired = errors.New("selected character id required")
ErrCharacterNotFound = errors.New("character record not found")
ErrAccountMismatch = errors.New("character account does not match session account")
ErrStageOutOfSequence = errors.New("requested carry gold stage is not the next stage")
ErrStageExhausted = errors.New("carry gold stage is already at the client bound")
ErrGoldInsufficient = errors.New("carry gold upgrade cannot be paid for")
)
type Command struct {
AccountID string
SelectedCharacterID uint16
RequestedStage byte
UpdatedAt time.Time
}
// Result reports the character's durable state after the call. Stage and
// GoldAfter are always the persisted values, so a refusal still carries the
// stage the client has to be told about.
type Result struct {
CharacterID string
PreviousStage byte
Stage byte
Cost int64
GoldBefore int64
GoldAfter int64
Limit int64
Upgraded bool
}
// Owner owns the carry-gold stage rule and its character mutation. The bridge
// owns only the current EXE wire format and response ordering.
type Owner struct {
characters dnfrepo.CharacterRepository
}
func NewOwner(repos dnfrepo.Group) (*Owner, error) {
if repos.Character == nil {
return nil, ErrOwnerUnavailable
}
return &Owner{characters: repos.Character}, nil
}
// Load reports the durable stage and limit without mutating anything. It is
// what the login projection uses.
func (o *Owner) Load(ctx context.Context, selectedCharacterID uint16) (Result, error) {
character, characterID, err := o.load(ctx, selectedCharacterID, "")
if err != nil {
return Result{}, err
}
stage := Stage(character.Stats)
return Result{
CharacterID: characterID,
PreviousStage: stage,
Stage: stage,
GoldBefore: character.Stats["gold"],
GoldAfter: character.Stats["gold"],
Limit: LimitForCharacter(character),
}, nil
}
// Upgrade advances the durable stage by exactly one and charges StageCost.
// Gold is checked before any mutation, so a refusal leaves the character
// untouched.
func (o *Owner) Upgrade(ctx context.Context, cmd Command) (Result, error) {
character, characterID, err := o.load(ctx, cmd.SelectedCharacterID, cmd.AccountID)
if err != nil {
return Result{}, err
}
current := Stage(character.Stats)
gold := character.Stats["gold"]
result := Result{
CharacterID: characterID,
PreviousStage: current,
Stage: current,
Cost: StageCost,
GoldBefore: gold,
GoldAfter: gold,
Limit: LimitForCharacter(character),
}
if current >= MaxStage {
return result, fmt.Errorf("%w: character=%s stage=%d", ErrStageExhausted, characterID, current)
}
if cmd.RequestedStage != current+1 {
return result, fmt.Errorf(
"%w: character=%s stage=%d requested=%d",
ErrStageOutOfSequence, characterID, current, cmd.RequestedStage,
)
}
if gold < StageCost {
return result, fmt.Errorf(
"%w: character=%s gold=%d cost=%d",
ErrGoldInsufficient, characterID, gold, StageCost,
)
}
updated := dnfrepo.CloneCharacter(character)
if updated.Stats == nil {
updated.Stats = make(map[string]int64, 2)
}
goldAfter := gold - StageCost
updated.Stats["gold"] = goldAfter
updated.Stats[StageStat] = int64(cmd.RequestedStage)
if cmd.UpdatedAt.IsZero() {
updated.UpdatedAt = time.Now().UTC()
} else {
updated.UpdatedAt = cmd.UpdatedAt.UTC()
}
if err := dnfrepo.SaveCharacterFields(
ctx, o.characters, updated, dnfrepo.CharacterFieldStats,
); err != nil {
return result, err
}
result.Stage = cmd.RequestedStage
result.GoldAfter = goldAfter
result.Limit = LimitForCharacter(updated)
result.Upgraded = true
return result, nil
}
func (o *Owner) load(
ctx context.Context,
selectedCharacterID uint16,
accountID string,
) (dnfrepo.CharacterRecord, string, error) {
if o == nil || o.characters == nil {
return dnfrepo.CharacterRecord{}, "", ErrOwnerUnavailable
}
if selectedCharacterID == 0 {
return dnfrepo.CharacterRecord{}, "", ErrCharacterRequired
}
characterID := strconv.FormatUint(uint64(selectedCharacterID), 10)
character, found, err := o.characters.Load(ctx, characterID)
if err != nil {
return dnfrepo.CharacterRecord{}, characterID, err
}
if !found || strings.TrimSpace(character.CharacterID) != characterID {
return dnfrepo.CharacterRecord{}, characterID, fmt.Errorf(
"%w: character=%s", ErrCharacterNotFound, characterID)
}
if trimmed := strings.TrimSpace(accountID); trimmed != "" &&
strings.TrimSpace(character.AccountID) != trimmed {
return dnfrepo.CharacterRecord{}, characterID, fmt.Errorf(
"%w: character=%s", ErrAccountMismatch, characterID)
}
if character.Stats == nil {
character.Stats = make(map[string]int64, 2)
}
return character, characterID, nil
}
@@ -0,0 +1,44 @@
package carrygold
import (
"context"
"os"
"testing"
platformpvf "longheng.io/server/internal/platform/pvf"
)
// TestRealScriptPVFGoldLimitByLevel is the evidence behind the base carry cap.
// The runtime PVF really does carry `[gold limit from level]`, so the base
// limit is level-keyed data and must never be re-hardcoded as 4亿; only the
// per-stage 亿 and the flat StageCost come from the client.
func TestRealScriptPVFGoldLimitByLevel(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify the carry-gold level table")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatal(err)
}
limits, err := LoadLevelLimits(context.Background(), archive)
if err != nil {
t.Fatal(err)
}
if limits.Len() < 90 {
t.Fatalf("runtime %s has only %d level rows", LevelLimitPath, limits.Len())
}
// The level-60 plateau is what makes the client's confirm dialog read
// 4亿 -> 5亿 for a level-capped character.
for level, want := range map[int]int64{
0: 0, 1: 100_000, 20: 30_000_000, 30: 100_000_000,
60: 400_000_000, 90: 400_000_000, 99: 400_000_000,
} {
if got := limits.Limit(level); got != want {
t.Fatalf("runtime level=%d limit=%d want %d", level, got, want)
}
}
if base := limits.Limit(90); base+StageBonus(MaxStage) != int64(DisplayedLimit(MaxStage))*Unit {
t.Fatalf("level cap base=%d + max stage bonus does not match the client display", base)
}
}
@@ -0,0 +1,304 @@
package cerashop
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const (
accountCeraMetadataKey = "account_cera"
accountTokenCeraMetadataKey = "account_token_cera"
accountHappyTokenCeraMetadataKey = "account_happy_token_cera"
)
var (
ErrOwnerUnavailable = errors.New("cera shop owner is unavailable")
ErrAccountNotFound = errors.New("cera shop account is not found")
ErrCharacterNotFound = errors.New("cera shop character is not found")
ErrInventoryNotFound = errors.New("cera shop inventory is not found")
ErrAccountMismatch = errors.New("cera shop character account mismatch")
ErrCeraInsufficient = errors.New("cera shop cera balance is insufficient")
ErrProjectorRequired = errors.New("cera shop checkout projector is required")
)
type CheckoutChanges struct {
Character bool
Inventory bool
Equipment bool
Settings bool
}
type CheckoutAssets struct {
Account *dnfrepo.AccountRecord
Character *dnfrepo.CharacterRecord
Inventory *dnfrepo.InventoryRecord
Equipment *dnfrepo.EquipmentRecord
Settings *dnfrepo.SettingsRecord
EquipmentFound bool
SettingsFound bool
}
type CheckoutProjector func(*CheckoutAssets) (CheckoutChanges, error)
type CheckoutCommand struct {
AccountID string
CharacterID string
SettingsScope string
Cost int64
UpdatedAt time.Time
// PayPolicy 决定这笔钱从哪些钱包扣。
//
// **零值 = PayPolicyCeraOnly = 只扣点券 = 改动前的行为**,所以既有调用方一行都
// 不用改,也不会因为漏填这个字段而悄悄换一种扣费方式。
PayPolicy PayPolicy
Project CheckoutProjector
}
type CheckoutResult struct {
CeraBefore int64
CeraAfter int64
TokenCera int64
HappyTokenCera int64
// Paid 是本次结账各钱包实际扣掉的数量。
// 用于日志核对,也是"代币券真的能花掉"这条验收的凭据。
Paid WalletPayment
}
// Wallet is the complete account-owned Cera projection consumed by the
// current client's class0/op53 update packet.
type Wallet struct {
Cera int64
TokenCera int64
HappyTokenCera int64
}
type Owner struct {
assets dnfrepo.CeraShopAssetUnitOfWork
}
func NewOwner(repositories dnfrepo.Group) (*Owner, error) {
if repositories.CeraShopAssets == nil {
return nil, ErrOwnerUnavailable
}
return &Owner{assets: repositories.CeraShopAssets}, nil
}
func (o *Owner) Checkout(ctx context.Context, command CheckoutCommand) (CheckoutResult, error) {
accountID := strings.TrimSpace(command.AccountID)
characterID := strings.TrimSpace(command.CharacterID)
settingsScope := strings.TrimSpace(command.SettingsScope)
if o == nil || o.assets == nil || accountID == "" || characterID == "" || settingsScope == "" || command.Cost < 0 {
return CheckoutResult{}, ErrOwnerUnavailable
}
if command.Project == nil {
return CheckoutResult{}, ErrProjectorRequired
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return CheckoutResult{}, err
}
now := command.UpdatedAt.UTC()
if now.IsZero() {
now = time.Now().UTC()
}
var result CheckoutResult
err := o.assets.WithinCeraShopAssets(
ctx,
accountID,
characterID,
settingsScope,
func(
accounts dnfrepo.AccountRepository,
_ dnfrepo.AccountInventoryRepository,
characters dnfrepo.CharacterRepository,
inventories dnfrepo.InventoryRepository,
equipment dnfrepo.EquipmentRepository,
settings dnfrepo.SettingsRepository,
) error {
if accounts == nil || characters == nil || inventories == nil || equipment == nil || settings == nil {
return ErrOwnerUnavailable
}
account, found, err := accounts.Load(ctx, accountID)
if err != nil {
return err
}
if !found {
return ErrAccountNotFound
}
character, found, err := characters.Load(ctx, characterID)
if err != nil {
return err
}
if !found {
return ErrCharacterNotFound
}
if strings.TrimSpace(character.AccountID) != accountID {
return ErrAccountMismatch
}
inventory, found, err := inventories.Load(ctx, characterID)
if err != nil {
return err
}
if !found || strings.TrimSpace(inventory.CharacterID) != characterID {
return ErrInventoryNotFound
}
equipmentRecord, equipmentFound, err := equipment.Load(ctx, characterID)
if err != nil {
return err
}
if !equipmentFound {
equipmentRecord = dnfrepo.EquipmentRecord{CharacterID: characterID}
}
settingsRecord, settingsFound, err := settings.Load(ctx, settingsScope)
if err != nil {
return err
}
if !settingsFound {
settingsRecord = dnfrepo.SettingsRecord{Scope: settingsScope}
}
account = dnfrepo.CloneAccount(account)
character = dnfrepo.CloneCharacter(character)
inventory = dnfrepo.CloneInventory(inventory)
equipmentRecord = dnfrepo.CloneEquipment(equipmentRecord)
settingsRecord = dnfrepo.CloneSettings(settingsRecord)
ceraBefore := Balance(account)
// 按策略算扣费方案:默认(零值)就是"只扣点券",与改动前完全一致。
// 瀑布策略下代币券 / 欢乐代币券会先被花掉,点券只补差额。
payment, planErr := PlanWalletPayment(WalletBalance(account), command.Cost, command.PayPolicy)
if planErr != nil {
if errors.Is(planErr, ErrWalletInsufficient) && command.PayPolicy == PayPolicyCeraOnly {
// 保持既有错误口径:只用点券时仍然报 ErrCeraInsufficient,
// 免得改了错误类型却没人更新调用方的判定。
return fmt.Errorf("%w: balance=%d cost=%d", ErrCeraInsufficient, ceraBefore, command.Cost)
}
return planErr
}
ceraAfter := ceraBefore - payment.FromCera
ApplyWalletPayment(&account, payment)
assets := &CheckoutAssets{
Account: &account,
Character: &character,
Inventory: &inventory,
Equipment: &equipmentRecord,
Settings: &settingsRecord,
EquipmentFound: equipmentFound,
SettingsFound: settingsFound,
}
changes, err := command.Project(assets)
if err != nil {
return err
}
account.UpdatedAt = now
if err := accounts.Save(ctx, account); err != nil {
return err
}
if changes.Character {
character.UpdatedAt = now
if err := dnfrepo.SaveCharacterFields(ctx, characters, character, dnfrepo.CharacterFieldStats); err != nil {
return err
}
}
if changes.Inventory {
inventory.UpdatedAt = now
if err := dnfrepo.SaveInventoryFields(ctx, inventories, inventory, dnfrepo.InventoryFieldSlots); err != nil {
return err
}
}
if changes.Equipment {
equipmentRecord.UpdatedAt = now
if err := dnfrepo.SaveEquipmentFields(ctx, equipment, equipmentRecord, dnfrepo.EquipmentFieldEntries); err != nil {
return err
}
}
if changes.Settings {
settingsRecord.UpdatedAt = now
if err := dnfrepo.SaveSettingsFields(ctx, settings, settingsRecord, dnfrepo.SettingsFieldValues); err != nil {
return err
}
}
wallet := WalletBalance(account)
result = CheckoutResult{
CeraBefore: ceraBefore,
CeraAfter: ceraAfter,
TokenCera: wallet.TokenCera,
HappyTokenCera: wallet.HappyTokenCera,
Paid: payment,
}
return nil
},
)
if err != nil {
return CheckoutResult{}, err
}
return result, nil
}
func Balance(account dnfrepo.AccountRecord) int64 {
return metadataBalance(account, accountCeraMetadataKey)
}
func TokenCeraBalance(account dnfrepo.AccountRecord) int64 {
return metadataBalance(account, accountTokenCeraMetadataKey)
}
func HappyTokenCeraBalance(account dnfrepo.AccountRecord) int64 {
return metadataBalance(account, accountHappyTokenCeraMetadataKey)
}
func WalletBalance(account dnfrepo.AccountRecord) Wallet {
return Wallet{
Cera: Balance(account),
TokenCera: TokenCeraBalance(account),
HappyTokenCera: HappyTokenCeraBalance(account),
}
}
func metadataBalance(account dnfrepo.AccountRecord, key string) int64 {
raw := strings.TrimSpace(account.Metadata[key])
if raw == "" {
return 0
}
value, err := strconv.ParseInt(raw, 10, 64)
if err != nil || value < 0 {
return 0
}
return value
}
func SetBalance(account *dnfrepo.AccountRecord, balance int64) {
setMetadataBalance(account, accountCeraMetadataKey, balance)
}
func SetTokenCeraBalance(account *dnfrepo.AccountRecord, balance int64) {
setMetadataBalance(account, accountTokenCeraMetadataKey, balance)
}
func SetHappyTokenCeraBalance(account *dnfrepo.AccountRecord, balance int64) {
setMetadataBalance(account, accountHappyTokenCeraMetadataKey, balance)
}
func setMetadataBalance(account *dnfrepo.AccountRecord, key string, balance int64) {
if account == nil {
return
}
if account.Metadata == nil {
account.Metadata = make(map[string]string, 4)
}
if balance < 0 {
balance = 0
}
account.Metadata[key] = strconv.FormatInt(balance, 10)
}
@@ -0,0 +1,129 @@
package cerashop
import (
"context"
"errors"
"testing"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestCheckoutCommitsWalletAndAllProjectedAssets(t *testing.T) {
ctx := context.Background()
repositories := seededCheckoutRepositories(t, 1000)
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
result, err := owner.Checkout(ctx, CheckoutCommand{
AccountID: "account-1",
CharacterID: "22",
SettingsScope: dnfrepo.CharacterContainerStateScope("22"),
Cost: 100,
Project: func(assets *CheckoutAssets) (CheckoutChanges, error) {
assets.Character.Stats = map[string]int64{"name_tag_item_id": 123}
assets.Inventory.Slots = map[string]dnfrepo.ItemStack{"0:9": {ItemID: 456, Count: 1}}
assets.Equipment.Entries = map[string]dnfrepo.EquipmentEntry{"30": {SlotIndex: 30, ItemID: 123}}
assets.Settings.Values = map[string]string{"main_list_param16": "8"}
return CheckoutChanges{Character: true, Inventory: true, Equipment: true, Settings: true}, nil
},
})
if err != nil {
t.Fatal(err)
}
if result.CeraBefore != 1000 || result.CeraAfter != 900 {
t.Fatalf("checkout result=%+v", result)
}
account, _, _ := repositories.Account.Load(ctx, "account-1")
character, _, _ := repositories.Character.Load(ctx, "22")
inventory, _, _ := repositories.Inventory.Load(ctx, "22")
equipment, _, _ := repositories.Equipment.Load(ctx, "22")
settings, found, _ := repositories.Settings.Load(ctx, dnfrepo.CharacterContainerStateScope("22"))
if Balance(account) != 900 ||
character.Stats["name_tag_item_id"] != 123 ||
inventory.Slots["0:9"].ItemID != 456 ||
equipment.Entries["30"].ItemID != 123 ||
!found || settings.Values["main_list_param16"] != "8" {
t.Fatalf("account=%+v character=%+v inventory=%+v equipment=%+v settings=%+v", account, character, inventory, equipment, settings)
}
}
func TestCheckoutRollsBackProjectionFailure(t *testing.T) {
ctx := context.Background()
repositories := seededCheckoutRepositories(t, 1000)
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
projectErr := errors.New("projection rejected")
_, err = owner.Checkout(ctx, CheckoutCommand{
AccountID: "account-1",
CharacterID: "22",
SettingsScope: dnfrepo.CharacterContainerStateScope("22"),
Cost: 100,
Project: func(assets *CheckoutAssets) (CheckoutChanges, error) {
assets.Inventory.Slots = make(map[string]dnfrepo.ItemStack)
assets.Inventory.Slots["0:9"] = dnfrepo.ItemStack{ItemID: 456, Count: 1}
assets.Settings.Values = map[string]string{"main_list_param16": "8"}
return CheckoutChanges{Inventory: true, Settings: true}, projectErr
},
})
if !errors.Is(err, projectErr) {
t.Fatalf("error=%v want=%v", err, projectErr)
}
account, _, _ := repositories.Account.Load(ctx, "account-1")
inventory, _, _ := repositories.Inventory.Load(ctx, "22")
if Balance(account) != 1000 || len(inventory.Slots) != 0 {
t.Fatalf("account=%+v inventory=%+v", account, inventory)
}
if _, found, _ := repositories.Settings.Load(ctx, dnfrepo.CharacterContainerStateScope("22")); found {
t.Fatal("failed checkout persisted settings")
}
}
func TestCheckoutRejectsInsufficientCeraBeforeProjection(t *testing.T) {
repositories := seededCheckoutRepositories(t, 50)
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
projected := false
_, err = owner.Checkout(context.Background(), CheckoutCommand{
AccountID: "account-1",
CharacterID: "22",
SettingsScope: dnfrepo.CharacterContainerStateScope("22"),
Cost: 100,
Project: func(*CheckoutAssets) (CheckoutChanges, error) {
projected = true
return CheckoutChanges{}, nil
},
})
if !errors.Is(err, ErrCeraInsufficient) || projected {
t.Fatalf("error=%v projected=%t", err, projected)
}
}
func seededCheckoutRepositories(t *testing.T, cera int64) dnfrepo.Group {
t.Helper()
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
account := dnfrepo.AccountRecord{AccountID: "account-1"}
SetBalance(&account, cera)
if err := repositories.Account.Save(ctx, account); err != nil {
t.Fatal(err)
}
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "22",
AccountID: "account-1",
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "22",
Slots: make(map[string]dnfrepo.ItemStack),
}); err != nil {
t.Fatal(err)
}
return repositories
}
@@ -0,0 +1,136 @@
package cerashop
import (
"errors"
"fmt"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// 商城的钱包扣费策略。
//
// 现场背景:客户端**只上报"支付方式 0 = 点券"**,没有任何"用代币券买"的选项
// (见 dnfbridge 的 currentCeraShopPaymentMode*)。也就是说代币券 / 欢乐代币券
// 能不能花掉,完全由服务端决定 —— 服务端不主动从这两个钱包扣,它们就是一组
// 只能看不能用的数字(这正是现场 2026-09-19 抱怨的另一半)。
//
// 所以这里实现官方的"瀑布"语义:欢乐代币券 → 代币券 → 点券,依次扣到够为止。
type PayPolicy int
const (
// PayPolicyCeraOnly 只扣点券,**这是零值**。
//
// 零值必须等于改动前的行为:既有调用方一行都不用改,也不会因为漏传字段而
// 悄悄换一种扣费方式(Go 里"新增 bool 字段默认 false"就是这种陷阱)。
PayPolicyCeraOnly PayPolicy = iota
// PayPolicyWaterfall 欢乐代币券 → 代币券 → 点券,依次扣。
PayPolicyWaterfall
// PayPolicyTokenCeraOnly 只扣欢乐代币券 + 代币券,不动点券。
// 对应客户端支持、而本服 PVF 尚未配置的 `[buy only cera point]` 商品;
// 余额不够就整笔失败。
PayPolicyTokenCeraOnly
)
// ErrWalletInsufficient 表示按给定策略扣不动这笔钱。
//
// 与 ErrCeraInsufficient(只针对点券)分开:调用方需要区分"点券不够"和
// "代币券+点券都不够",日志里才看得清玩家到底缺哪种钱。
var ErrWalletInsufficient = errors.New("cera shop: wallet balance is insufficient for the payment policy")
// WalletPayment 是一次结账从各钱包实际扣掉的数量。
type WalletPayment struct {
FromHappyTokenCera int64
FromTokenCera int64
FromCera int64
}
// Total 是这次结账各钱包扣费之和。
func (p WalletPayment) Total() int64 {
return p.FromHappyTokenCera + p.FromTokenCera + p.FromCera
}
// IsZero 表示没有从任何钱包扣钱。
func (p WalletPayment) IsZero() bool {
return p.FromHappyTokenCera == 0 && p.FromTokenCera == 0 && p.FromCera == 0
}
// String 便于日志与断言(例如 "happy=100 token=50 cera=0")。
func (p WalletPayment) String() string {
return fmt.Sprintf("happy=%d token=%d cera=%d", p.FromHappyTokenCera, p.FromTokenCera, p.FromCera)
}
// PlanWalletPayment 按策略算出各钱包要扣多少,**不改动传入的钱包**。
//
// 返回的 WalletPayment 保证 Total() == cost(成功时);失败时返回零值与错误。
// 这是一个纯函数,方便把"扣费顺序"这类最容易出错的逻辑单独钉死。
func PlanWalletPayment(wallet Wallet, cost int64, policy PayPolicy) (WalletPayment, error) {
if cost < 0 {
return WalletPayment{}, fmt.Errorf("%w: negative cost %d", ErrWalletInsufficient, cost)
}
if cost == 0 {
return WalletPayment{}, nil
}
// 负数余额只可能来自脏数据;当成 0,绝不让它把扣费算成"多扣"。
happy := wallet.HappyTokenCera
if happy < 0 {
happy = 0
}
token := wallet.TokenCera
if token < 0 {
token = 0
}
cera := wallet.Cera
if cera < 0 {
cera = 0
}
var payment WalletPayment
remaining := cost
// 两种非"纯点券"的策略都先花欢乐代币券:它是活动货币,优先级最高。
if policy == PayPolicyWaterfall || policy == PayPolicyTokenCeraOnly {
payment.FromHappyTokenCera = minInt64(remaining, happy)
remaining -= payment.FromHappyTokenCera
if remaining > 0 {
payment.FromTokenCera = minInt64(remaining, token)
remaining -= payment.FromTokenCera
}
}
if policy == PayPolicyWaterfall && remaining > 0 {
payment.FromCera = minInt64(remaining, cera)
remaining -= payment.FromCera
}
// 未知策略一律按"只扣点券"处理,绝不猜一种更宽松的扣法。
if policy != PayPolicyWaterfall && policy != PayPolicyTokenCeraOnly {
payment = WalletPayment{}
remaining = cost
payment.FromCera = minInt64(remaining, cera)
remaining -= payment.FromCera
}
if remaining > 0 {
return WalletPayment{}, fmt.Errorf("%w: policy=%d cost=%d wallet=%+v", ErrWalletInsufficient, policy, cost, wallet)
}
return payment, nil
}
// ApplyWalletPayment 把扣费方案写回账号钱包,并返回扣费后的钱包。
//
// 调用方负责保存账号记录(本函数只改内存里的记录,与仓库无关,便于在事务回调里用)。
// 每个字段都夹到 >= 0:方案本身已经保证不会扣超,这里只是最后一道防线。
func ApplyWalletPayment(account *dnfrepo.AccountRecord, payment WalletPayment) Wallet {
if account == nil {
return Wallet{}
}
wallet := WalletBalance(*account)
SetHappyTokenCeraBalance(account, wallet.HappyTokenCera-payment.FromHappyTokenCera)
SetTokenCeraBalance(account, wallet.TokenCera-payment.FromTokenCera)
SetBalance(account, wallet.Cera-payment.FromCera)
return WalletBalance(*account)
}
func minInt64(left, right int64) int64 {
if left < right {
return left
}
return right
}
@@ -0,0 +1,170 @@
package cerashop
import (
"errors"
"testing"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// 支付瀑布:欢乐代币券 → 代币券 → 点券。
//
// 客户端只上报"支付方式 0 = 点券",没有"用代币券买"的选项,所以代币券能不能花掉
// 完全由服务端决定。这一组测试把"扣费顺序"钉死——它是最容易写错、也最容易
// 悄悄扣错钱包的地方。
func TestPlanWalletPaymentCeraOnlyIsTheZeroValue(t *testing.T) {
wallet := Wallet{Cera: 100, TokenCera: 500, HappyTokenCera: 500}
// 零值必须是"只扣点券":既有调用方一行不改也不该换扣费方式。
var policy PayPolicy
if policy != PayPolicyCeraOnly {
t.Fatalf("PayPolicy 零值=%v want PayPolicyCeraOnly", policy)
}
payment, err := PlanWalletPayment(wallet, 80, policy)
if err != nil {
t.Fatal(err)
}
if payment.FromCera != 80 || payment.FromTokenCera != 0 || payment.FromHappyTokenCera != 0 {
t.Fatalf("只扣点券模式动了别的钱包: %s", payment)
}
// 点券不够就该失败,而不是把代币券也花了。
if _, err := PlanWalletPayment(wallet, 101, PayPolicyCeraOnly); !errors.Is(err, ErrWalletInsufficient) {
t.Fatalf("点券不够应失败: %v", err)
}
}
func TestPlanWalletPaymentWaterfallSpendsInOrder(t *testing.T) {
cases := []struct {
name string
wallet Wallet
cost int64
want WalletPayment
wantErr bool
}{
{
name: "happy_covers_all",
wallet: Wallet{Cera: 999, TokenCera: 999, HappyTokenCera: 100},
cost: 40,
want: WalletPayment{FromHappyTokenCera: 40},
},
{
name: "happy_exhausted_then_token",
wallet: Wallet{Cera: 999, TokenCera: 100, HappyTokenCera: 30},
cost: 50,
want: WalletPayment{FromHappyTokenCera: 30, FromTokenCera: 20},
},
{
name: "happy_and_token_exhausted_then_cera",
wallet: Wallet{Cera: 999, TokenCera: 20, HappyTokenCera: 10},
cost: 100,
want: WalletPayment{FromHappyTokenCera: 10, FromTokenCera: 20, FromCera: 70},
},
{
name: "exactly_enough_across_all_three",
wallet: Wallet{Cera: 70, TokenCera: 20, HappyTokenCera: 10},
cost: 100,
want: WalletPayment{FromHappyTokenCera: 10, FromTokenCera: 20, FromCera: 70},
},
{
name: "one_short",
wallet: Wallet{Cera: 69, TokenCera: 20, HappyTokenCera: 10},
cost: 100,
wantErr: true,
},
{
name: "zero_cera_still_payable_with_token",
wallet: Wallet{Cera: 0, TokenCera: 300, HappyTokenCera: 0},
cost: 250,
want: WalletPayment{FromTokenCera: 250},
},
{
name: "cost_zero_is_a_no_op",
wallet: Wallet{Cera: 5, TokenCera: 5, HappyTokenCera: 5},
cost: 0,
want: WalletPayment{},
},
{
// 负数余额只可能来自脏数据,当成 0,绝不能让扣费算出"多扣"。
name: "negative_balances_treated_as_zero",
wallet: Wallet{Cera: 100, TokenCera: -50, HappyTokenCera: -50},
cost: 100,
want: WalletPayment{FromCera: 100},
},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
payment, err := PlanWalletPayment(testCase.wallet, testCase.cost, PayPolicyWaterfall)
if testCase.wantErr {
if !errors.Is(err, ErrWalletInsufficient) {
t.Fatalf("期望余额不足,实际 err=%v payment=%s", err, payment)
}
if !payment.IsZero() {
t.Fatalf("失败时必须返回零方案,绝不部分扣费: %s", payment)
}
return
}
if err != nil {
t.Fatal(err)
}
if payment != testCase.want {
t.Fatalf("payment=%s want=%s", payment, testCase.want)
}
if payment.Total() != testCase.cost {
t.Fatalf("扣费总额=%d want=%d", payment.Total(), testCase.cost)
}
})
}
}
// `[buy only cera point]` 商品:不能动点券。
func TestPlanWalletPaymentTokenCeraOnlyNeverTouchesCera(t *testing.T) {
wallet := Wallet{Cera: 100000, TokenCera: 30, HappyTokenCera: 10}
payment, err := PlanWalletPayment(wallet, 40, PayPolicyTokenCeraOnly)
if err != nil {
t.Fatal(err)
}
if payment.FromCera != 0 || payment.FromHappyTokenCera != 10 || payment.FromTokenCera != 30 {
t.Fatalf("不该动点券: %s", payment)
}
// 券不够时失败,即使点券充足。
if _, err := PlanWalletPayment(wallet, 41, PayPolicyTokenCeraOnly); !errors.Is(err, ErrWalletInsufficient) {
t.Fatalf("券不够应失败(点券再多也不能用): %v", err)
}
}
// 未知策略一律按"只扣点券"处理:绝不猜一种更宽松的扣法。
func TestPlanWalletPaymentUnknownPolicyFallsBackToCeraOnly(t *testing.T) {
wallet := Wallet{Cera: 100, TokenCera: 500, HappyTokenCera: 500}
payment, err := PlanWalletPayment(wallet, 60, PayPolicy(99))
if err != nil {
t.Fatal(err)
}
if payment.FromCera != 60 || payment.FromTokenCera != 0 || payment.FromHappyTokenCera != 0 {
t.Fatalf("未知策略不该动代币券钱包: %s", payment)
}
}
func TestPlanWalletPaymentRejectsNegativeCost(t *testing.T) {
if _, err := PlanWalletPayment(Wallet{Cera: 100}, -1, PayPolicyWaterfall); !errors.Is(err, ErrWalletInsufficient) {
t.Fatalf("负成本应失败: %v", err)
}
}
func TestApplyWalletPaymentWritesOnlyThePlannedFields(t *testing.T) {
account := &dnfrepo.AccountRecord{AccountID: "account-1", Metadata: map[string]string{}}
SetBalance(account, 700)
SetTokenCeraBalance(account, 100)
SetHappyTokenCeraBalance(account, 50)
after := ApplyWalletPayment(account, WalletPayment{FromHappyTokenCera: 50, FromTokenCera: 20, FromCera: 30})
want := Wallet{Cera: 670, TokenCera: 80, HappyTokenCera: 0}
if after != want {
t.Fatalf("after=%+v want=%+v", after, want)
}
if wallet := WalletBalance(*account); wallet != want {
t.Fatalf("账号记录=%+v want=%+v", wallet, want)
}
// nil 账号不该 panic(调用方多在事务回调里,panic 会连累整条连接)。
if got := ApplyWalletPayment(nil, WalletPayment{FromCera: 1}); got != (Wallet{}) {
t.Fatalf("nil 账号应返回零钱包: %+v", got)
}
}
@@ -0,0 +1,143 @@
// Package cerashop:代币券 / 欢乐代币券的"入钱包"语义。
//
// 背景(现场 2026-09-19):代币券和欢乐代币券在道具 PVF 里是普通
// `[stackable type] [material]`,既没有 `[action type]` 也没有任何"使用后转成点券"
// 的定义 —— 也就是说**客户端自己根本无法把它们变成钱包余额**。任何把它们当普通道具
// 发进背包的路径,玩家拿到的都是一件永远用不掉的"材料",这正是现场的抱怨。
//
// 所以转换 100% 由服务端负责:发放时识别券的道具号,直接累加到账号钱包,不占背包格。
//
// 道具号来源(真机 runtime/data/dnf/Script.pvf):
//
// stackable/cash/chn_Trade_Gold/trade_point.stk id 2681762 `代币券`
// stackable/cash/chn_Trade_Gold/trade_point_event.stk id 2681917 `欢乐代币券`
package cerashop
import (
"errors"
"math"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const (
// TokenCeraVoucherItemID 是 `代币券`(trade_point.stk)。
TokenCeraVoucherItemID int64 = 2681762
// HappyTokenCeraVoucherItemID 是 `欢乐代币券`(trade_point_event.stk)。
HappyTokenCeraVoucherItemID int64 = 2681917
)
// MaxWalletBalance 是三个钱包字段的上限。
//
// 客户端把这三个值按 **有符号 32 位** 读进字段(反编译 `class0/0x0035` 的 handler
// `FUN_01d83c20` 读的是 i32),所以上限必须是 MaxInt32 而不是 MaxInt64:
// 超过就会在客户端显示成负数。所有入账都必须在这里挡住。
const MaxWalletBalance int64 = math.MaxInt32
// ErrWalletOverflow 表示入账会让钱包超过 MaxWalletBalance。
// 调用方应该把它映射成自己模块的"请求非法"错误,而不是静默截断。
var ErrWalletOverflow = errors.New("cera shop: wallet balance would exceed the client limit")
// VoucherKind 是券对应的钱包字段。
type VoucherKind int
const (
// VoucherNone 表示这件道具不是券。
VoucherNone VoucherKind = iota
// VoucherTokenCera 是代币券,入账到 Wallet.TokenCera。
VoucherTokenCera
// VoucherHappyTokenCera 是欢乐代币券,入账到 Wallet.HappyTokenCera。
VoucherHappyTokenCera
)
// VoucherKindOf 判断一件道具是不是券。不是券返回 VoucherNone。
func VoucherKindOf(itemID int64) VoucherKind {
switch itemID {
case TokenCeraVoucherItemID:
return VoucherTokenCera
case HappyTokenCeraVoucherItemID:
return VoucherHappyTokenCera
default:
return VoucherNone
}
}
// IsVoucher 是上面的布尔简写,给"这批奖励里有没有券"这类判断用。
func IsVoucher(itemID int64) bool {
return VoucherKindOf(itemID) != VoucherNone
}
// VoucherItemID 返回某类券的道具号(VoucherNone 返回 0)。
func VoucherItemID(kind VoucherKind) int64 {
switch kind {
case VoucherTokenCera:
return TokenCeraVoucherItemID
case VoucherHappyTokenCera:
return HappyTokenCeraVoucherItemID
default:
return 0
}
}
// VoucherItemIDs 返回全部券道具号,供"发放前先摘出来"的过滤使用。
func VoucherItemIDs() []int64 {
return []int64{TokenCeraVoucherItemID, HappyTokenCeraVoucherItemID}
}
// VoucherCounts 是一批待入账的券数量。
type VoucherCounts struct {
TokenCera int64
HappyTokenCera int64
}
// IsZero 表示没有任何券需要入账。
func (c VoucherCounts) IsZero() bool {
return c.TokenCera == 0 && c.HappyTokenCera == 0
}
// Add 累加另一种券的数量。
func (c VoucherCounts) Add(other VoucherCounts) VoucherCounts {
return VoucherCounts{
TokenCera: c.TokenCera + other.TokenCera,
HappyTokenCera: c.HappyTokenCera + other.HappyTokenCera,
}
}
// CreditVoucherCounts 把券数量累加到账号钱包。
//
// 语义与既有邮件领取路径完全一致(那里已经这么做了),本函数只是把它提取成共享实现,
// 让任务奖励、礼盒开启、GM 补发都走同一条路,避免各写一套溢出判断。
//
// - **全有或全无**:任何一项溢出都不写任何字段,账号记录保持原样,返回 ErrWalletOverflow。
// - 调用方负责保存 account 与它的 UpdatedAt。
// - 负数一律当 0:券数量为负只可能来自上游 bug,不该把余额扣下去。
func CreditVoucherCounts(account *dnfrepo.AccountRecord, counts VoucherCounts) (Wallet, error) {
if account == nil {
return Wallet{}, errors.New("cera shop: account is required")
}
tokenCera := counts.TokenCera
if tokenCera < 0 {
tokenCera = 0
}
happyTokenCera := counts.HappyTokenCera
if happyTokenCera < 0 {
happyTokenCera = 0
}
if tokenCera == 0 && happyTokenCera == 0 {
return WalletBalance(*account), nil
}
wallet := WalletBalance(*account)
if tokenCera > MaxWalletBalance-wallet.TokenCera {
return wallet, ErrWalletOverflow
}
if happyTokenCera > MaxWalletBalance-wallet.HappyTokenCera {
return wallet, ErrWalletOverflow
}
if tokenCera > 0 {
SetTokenCeraBalance(account, wallet.TokenCera+tokenCera)
}
if happyTokenCera > 0 {
SetHappyTokenCeraBalance(account, wallet.HappyTokenCera+happyTokenCera)
}
return WalletBalance(*account), nil
}
@@ -0,0 +1,126 @@
package cerashop
import (
"errors"
"math"
"testing"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// 代币券 / 欢乐代币券必须能落成钱包余额,而不是背包里的一件"材料"。
// 这里守住券的道具号识别与入账语义(溢出、全有或全无)。
func TestVoucherKindOfRecognizesBothVouchers(t *testing.T) {
cases := []struct {
itemID int64
want VoucherKind
}{
{2681762, VoucherTokenCera},
{2681917, VoucherHappyTokenCera},
{0, VoucherNone},
{2681762 - 1, VoucherNone},
{2681762 + 1, VoucherNone},
{2681917 + 1, VoucherNone},
{100050, VoucherNone},
}
for _, testCase := range cases {
if got := VoucherKindOf(testCase.itemID); got != testCase.want {
t.Fatalf("VoucherKindOf(%d)=%v want=%v", testCase.itemID, got, testCase.want)
}
if IsVoucher(testCase.itemID) != (testCase.want != VoucherNone) {
t.Fatalf("IsVoucher(%d) 与 VoucherKindOf 不一致", testCase.itemID)
}
}
if VoucherItemID(VoucherTokenCera) != 2681762 || VoucherItemID(VoucherHappyTokenCera) != 2681917 ||
VoucherItemID(VoucherNone) != 0 {
t.Fatal("VoucherItemID 与 VoucherKindOf 不是同一套映射")
}
if ids := VoucherItemIDs(); len(ids) != 2 || !IsVoucher(ids[0]) || !IsVoucher(ids[1]) {
t.Fatalf("VoucherItemIDs=%v", ids)
}
}
func TestCreditVoucherCountsAddsBothWallets(t *testing.T) {
account := &dnfrepo.AccountRecord{
AccountID: "account-1",
Metadata: map[string]string{"account_cera": "700", "account_token_cera": "5"},
}
wallet, err := CreditVoucherCounts(account, VoucherCounts{TokenCera: 100, HappyTokenCera: 250})
if err != nil {
t.Fatal(err)
}
if wallet.Cera != 700 || wallet.TokenCera != 105 || wallet.HappyTokenCera != 250 {
t.Fatalf("钱包=%+v want cera=700 token=105 happy=250", wallet)
}
// 点券字段绝不能被券的入账碰到。
if got := Balance(*account); got != 700 {
t.Fatalf("点券被改动了: %d", got)
}
}
// 数量为 0 时是空操作,不该写任何键(否则会给账号留下无意义的新元数据)。
func TestCreditVoucherCountsIgnoresZeroAndNegative(t *testing.T) {
account := &dnfrepo.AccountRecord{AccountID: "account-1"}
wallet, err := CreditVoucherCounts(account, VoucherCounts{})
if err != nil || wallet.TokenCera != 0 || wallet.HappyTokenCera != 0 {
t.Fatalf("空入账 err=%v wallet=%+v", err, wallet)
}
if len(account.Metadata) != 0 {
t.Fatalf("空入账不该写元数据: %v", account.Metadata)
}
// 负数只可能来自上游 bug,按 0 处理而不是把余额扣下去。
if _, err := CreditVoucherCounts(account, VoucherCounts{TokenCera: -50, HappyTokenCera: -1}); err != nil {
t.Fatal(err)
}
if wallet := WalletBalance(*account); wallet.TokenCera != 0 || wallet.HappyTokenCera != 0 {
t.Fatalf("负数不该扣余额: %+v", wallet)
}
}
// 溢出必须整笔拒绝,且**一个字段都不写**(全有或全无)——否则会出现
// "代币券溢出失败、欢乐代币券却已经进账"的半截状态。
func TestCreditVoucherCountsRefusesOverflowAtomically(t *testing.T) {
account := &dnfrepo.AccountRecord{
AccountID: "account-1",
Metadata: map[string]string{"account_token_cera": "2147483647"},
}
_, err := CreditVoucherCounts(account, VoucherCounts{TokenCera: 1, HappyTokenCera: 100})
if !errors.Is(err, ErrWalletOverflow) {
t.Fatalf("代币券溢出应返回 ErrWalletOverflow,实际=%v", err)
}
if wallet := WalletBalance(*account); wallet.TokenCera != math.MaxInt32 || wallet.HappyTokenCera != 0 {
t.Fatalf("溢出时不该写任何字段: %+v", wallet)
}
// 边界:刚好填满到上限是允许的(不是溢出)。
account = &dnfrepo.AccountRecord{AccountID: "account-1"}
wallet, err := CreditVoucherCounts(account, VoucherCounts{TokenCera: math.MaxInt32})
if err != nil || wallet.TokenCera != math.MaxInt32 {
t.Fatalf("刚好到上限应当允许: err=%v wallet=%+v", err, wallet)
}
// 再 +1 就溢出。
if _, err := CreditVoucherCounts(account, VoucherCounts{TokenCera: 1}); !errors.Is(err, ErrWalletOverflow) {
t.Fatalf("超过上限应溢出,实际=%v", err)
}
}
// nil 账号必须报错而不是 panic:调用方多为事务回调,panic 会连累整条连接。
func TestCreditVoucherCountsRejectsNilAccount(t *testing.T) {
if _, err := CreditVoucherCounts(nil, VoucherCounts{TokenCera: 1}); err == nil {
t.Fatal("nil 账号应报错")
}
}
func TestVoucherCountsAddAndIsZero(t *testing.T) {
if !(VoucherCounts{}).IsZero() {
t.Fatal("空 counts 应当是 zero")
}
if (VoucherCounts{TokenCera: 1}).IsZero() {
t.Fatal("非空 counts 不该是 zero")
}
sum := VoucherCounts{TokenCera: 3}.Add(VoucherCounts{TokenCera: 4, HappyTokenCera: 5})
if sum.TokenCera != 7 || sum.HappyTokenCera != 5 {
t.Fatalf("Add=%+v", sum)
}
}
@@ -0,0 +1,80 @@
package cerashop
import (
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// WalletCurrency 按名字标识账号钱包里的一个字段,供 GM / 管理接口读写。
//
// 存在的理由:钱包的三个字段分别落在账号 Metadata 的三个键上,键名是 cerashop 的
// 内部细节。GM 接口需要按名字操作它们(尤其是造测试数据:把点券清零、只留代币券),
// 所以在这里开一个受控的出口,而不是让调用方去猜 "account_token_cera" 这种字符串。
type WalletCurrency string
const (
// CurrencyCera 是点券。
CurrencyCera WalletCurrency = "cera"
// CurrencyTokenCera 是代币券。
CurrencyTokenCera WalletCurrency = "token_cera"
// CurrencyHappyTokenCera 是欢乐代币券。
CurrencyHappyTokenCera WalletCurrency = "happy_token_cera"
)
// MetadataKey 返回该字段在账号 Metadata 里的键名。
func (c WalletCurrency) MetadataKey() (string, bool) {
switch c {
case CurrencyCera:
return accountCeraMetadataKey, true
case CurrencyTokenCera:
return accountTokenCeraMetadataKey, true
case CurrencyHappyTokenCera:
return accountHappyTokenCeraMetadataKey, true
default:
return "", false
}
}
// Known 表示这是一个合法的钱包字段名。
func (c WalletCurrency) Known() bool {
_, ok := c.MetadataKey()
return ok
}
// BalanceOf 读该字段的当前值(未知字段返回 0,false)。
func (c WalletCurrency) BalanceOf(account dnfrepo.AccountRecord) (int64, bool) {
switch c {
case CurrencyCera:
return Balance(account), true
case CurrencyTokenCera:
return TokenCeraBalance(account), true
case CurrencyHappyTokenCera:
return HappyTokenCeraBalance(account), true
default:
return 0, false
}
}
// SetOn 把值写进账号记录(调用方负责保存账号记录)。未知字段返回 false,不做任何改动。
//
// 值会被夹到 [0, MaxWalletBalance]:客户端按有符号 32 位读这三个字段,超过上限会
// 显示成负数,所以这里直接挡住,而不是写进去等客户端出问题。
func (c WalletCurrency) SetOn(account *dnfrepo.AccountRecord, value int64) bool {
if account == nil || !c.Known() {
return false
}
if value < 0 {
value = 0
}
if value > MaxWalletBalance {
value = MaxWalletBalance
}
switch c {
case CurrencyCera:
SetBalance(account, value)
case CurrencyTokenCera:
SetTokenCeraBalance(account, value)
case CurrencyHappyTokenCera:
SetHappyTokenCeraBalance(account, value)
}
return true
}
@@ -0,0 +1,309 @@
// catalog.go 负责把 DNF channel_info.etc 转成频道广告和 game 监听共用的只读目录。
package channelcatalog
import (
"errors"
"sort"
"strconv"
"strings"
"longheng.io/server/internal/modules/dnf/channelinfo"
"longheng.io/server/internal/modules/dnf/dnfenum"
)
var ErrEmpty = errors.New("dnf channel catalog is empty")
// Channel 是协议广告和游戏端口监听共用的频道视图。
type Channel struct {
ServerID int
ID int
Type uint8
Group string
AreaDungeonIDs []int
Name string
// NoticeName 只用于 game class0/op1 首包;必须和 channel_info.etc 的频道 ID 对齐。
NoticeName string
MaxUsers int
CurrentUsers int
Port int
}
// Options 控制频道目录生成规则;零值使用 DNF 默认端口和人数。
type Options struct {
ServerID int
GamePortBase int
MaxUsers int
CurrentUsers int
}
// Catalog 是从 channel_info.etc 派生的只读频道目录。
type Catalog struct {
channels []Channel
byID map[int]Channel
byPort map[int]Channel
}
// New 从解析好的 channel_info.etc 索引构建游戏频道目录。
func New(index *channelinfo.Index, options Options) (*Catalog, error) {
options = normalizeOptions(options)
source := index.ChannelsForServer(options.ServerID)
if len(source) == 0 {
return nil, ErrEmpty
}
channels := make([]Channel, 0, len(source))
seen := make(map[int]struct{}, len(source))
for _, item := range source {
if item.ID <= 0 {
continue
}
if _, ok := seen[item.ID]; ok {
continue
}
seen[item.ID] = struct{}{}
area, _ := index.Area(item.AreaKey)
channels = append(channels, Channel{
ServerID: item.ServerID,
ID: item.ID,
Type: clampByte(item.Type),
Group: normalizeGroup(item.AreaKey),
AreaDungeonIDs: cloneInts(area.DungeonIDs),
Name: dnfenum.ChannelNamePrefix + strconv.Itoa(item.ID),
NoticeName: channelNoticeName(item),
MaxUsers: options.MaxUsers,
CurrentUsers: options.CurrentUsers,
Port: options.GamePortBase + item.ID,
})
}
if len(channels) == 0 {
return nil, ErrEmpty
}
catalog := &Catalog{
channels: cloneChannels(channels),
byID: make(map[int]Channel, len(channels)),
byPort: make(map[int]Channel, len(channels)),
}
for _, channel := range channels {
catalog.byID[channel.ID] = channel
catalog.byPort[channel.Port] = channel
}
return catalog, nil
}
// Channels 返回全部可广告频道,顺序与外部 channel_info.etc 保持一致。
func (c *Catalog) Channels() []Channel {
if c == nil {
return nil
}
return cloneChannels(c.channels)
}
// FilterForRequest 按客户端请求的频道组过滤广告列表。
func (c *Catalog) FilterForRequest(group string) []Channel {
if c == nil {
return nil
}
normalized := normalizeGroup(group)
if normalized == "" || normalized == dnfenum.GroupCain {
// The current client builds each channel-category control from this
// directory. Keep every authoritative ETC record here, including type 32,
// so the team-channel control has a non-empty source during bootstrap.
return cloneChannels(c.channels)
}
if normalized == dnfenum.GroupRaid || normalized == dnfenum.GroupAttackRaid {
return filterChannels(c.channels, isRaid)
}
matched := filterChannels(c.channels, func(channel Channel) bool {
return normalizeGroup(channel.Group) == normalized
})
if len(matched) > 0 {
return matched
}
return filterChannels(c.channels, func(channel Channel) bool {
return !isRaid(channel)
})
}
// FilterForBootstrap returns the directory used immediately after the client
// downloads channel_info.etc. Native tower and trade entrances cannot become
// the resident town channel during that bootstrap and are left for a later
// full directory refresh.
func (c *Catalog) FilterForBootstrap(group string) []Channel {
return filterChannels(c.FilterForRequest(group), func(channel Channel) bool {
return !isBootstrapEntrance(channel)
})
}
// Channel 按频道 ID 查询目录项。
func (c *Catalog) Channel(id int) (Channel, bool) {
if c == nil {
return Channel{}, false
}
channel, ok := c.byID[id]
return cloneChannel(channel), ok
}
// ForPort 按本地游戏端口反查频道。
func (c *Catalog) ForPort(port int) (Channel, bool) {
if c == nil {
return Channel{}, false
}
channel, ok := c.byPort[port]
return cloneChannel(channel), ok
}
// ResidentFor returns the ordinary town channel that the game client should
// commit after using a special bootstrap channel such as [crack]. The result
// is derived from channel_info.etc order; no channel id is fabricated here.
func (c *Catalog) ResidentFor(connected Channel) (Channel, bool) {
if c == nil {
return Channel{}, false
}
if candidate, ok := c.byID[connected.ID]; ok &&
candidate.ServerID == connected.ServerID && isResidentChannel(candidate) {
return cloneChannel(candidate), true
}
for _, channel := range c.channels {
if isResidentChannel(channel) {
return cloneChannel(channel), true
}
}
return Channel{}, false
}
// GamePorts 返回需要监听的游戏端口,按端口升序排列。
func (c *Catalog) GamePorts() []int {
if c == nil {
return nil
}
ports := make([]int, 0, len(c.byPort))
for port := range c.byPort {
ports = append(ports, port)
}
sort.Ints(ports)
return ports
}
func normalizeOptions(options Options) Options {
if options.ServerID <= 0 {
options.ServerID = dnfenum.LoginChannelServerIndex
}
if options.GamePortBase <= 0 {
options.GamePortBase = dnfenum.GamePortBase
}
if options.MaxUsers <= 0 {
options.MaxUsers = dnfenum.DefaultChannelMaxUsers
}
if options.CurrentUsers < 0 {
options.CurrentUsers = dnfenum.DefaultChannelCurrentUsers
}
return options
}
func filterChannels(channels []Channel, accept func(Channel) bool) []Channel {
out := make([]Channel, 0, len(channels))
for _, channel := range channels {
if accept(channel) {
out = append(out, cloneChannel(channel))
}
}
return out
}
// isRaid reports whether a channel belongs to a team-raid owner. The three
// raid owners are discriminated by type, not by group: the ETC writes
// `[anton_raid]` / `[luke_raid]` / `[fiendwar]`, so the group test alone would
// only ever match a channel literally grouped "raid".
func isRaid(channel Channel) bool {
return channel.Type == dnfenum.RaidChannelType ||
channel.Type == dnfenum.HiddenRaidType ||
channel.Type == dnfenum.FiendWarRaidChannelType ||
normalizeGroup(channel.Group) == dnfenum.GroupRaid
}
func isBootstrapEntrance(channel Channel) bool {
return channel.Type == 2 || channel.Type == 3
}
func isResidentChannel(channel Channel) bool {
group := normalizeGroup(channel.Group)
if group == "" || group == "none" || group == dnfenum.GroupCrack ||
group == dnfenum.GroupDeathTower || group == dnfenum.GroupTrade ||
group == dnfenum.GroupRaid || group == dnfenum.GroupAttackRaid {
return false
}
// Every raid owner is excluded here, Fiend War included: a resident channel
// is one the client may commit as an ordinary town, and town 98 has no
// ordinary-channel ownership any more than town 19 or town 32 does.
return channel.Type != dnfenum.DeathTowerChannelType &&
channel.Type != dnfenum.TradeChannelType &&
channel.Type != dnfenum.RaidChannelType &&
channel.Type != dnfenum.HiddenRaidType &&
channel.Type != dnfenum.FiendWarRaidChannelType
}
// SettableGuildChannel reports whether channel may be stored as a guild's
// 推荐频道, which is what game class1/op830 does.
//
// Only the channel group is consulted, deliberately. ResidentFor cannot be used
// for this: its type test reads type 11 as a death-tower channel, but type 11 is
// this catalog's ordinary town type (19/granfloris, 181/noblsky, 182/noblsky,
// ...), so ResidentFor resolved every town channel to the catalog's first
// resident channel and the guild handler answered op830 with error 19 for every
// ordinary channel -- live: the master of guild 1 standing in channel 181 was
// told 只有公会长才能设置.
func (c *Catalog) SettableGuildChannel(channel Channel) bool {
if c == nil {
return false
}
candidate, ok := c.byID[channel.ID]
if !ok || candidate.ServerID != channel.ServerID {
return false
}
group := normalizeGroup(candidate.Group)
if group == "" || group == "none" || group == dnfenum.GroupCrack ||
group == dnfenum.GroupDeathTower || group == dnfenum.GroupTrade ||
group == dnfenum.GroupRaid || group == dnfenum.GroupAttackRaid {
return false
}
return true
}
func normalizeGroup(value string) string {
value = strings.TrimSpace(value)
value = strings.Trim(value, "`")
value = strings.TrimPrefix(value, "[")
value = strings.TrimSuffix(value, "]")
return strings.ToLower(strings.TrimSpace(value))
}
func clampByte(value int) uint8 {
if value < 0 {
return 0
}
if value > 255 {
return 255
}
return uint8(value)
}
func channelNoticeName(item channelinfo.Channel) string {
// NameKey 是显示资源名,不能拿后缀拼出 etc 里不存在的频道。
return dnfenum.ChannelNamePrefix + strconv.Itoa(item.ID)
}
func cloneChannels(channels []Channel) []Channel {
out := make([]Channel, len(channels))
for idx, channel := range channels {
out[idx] = cloneChannel(channel)
}
return out
}
func cloneChannel(channel Channel) Channel {
channel.AreaDungeonIDs = cloneInts(channel.AreaDungeonIDs)
return channel
}
func cloneInts(values []int) []int {
return append([]int(nil), values...)
}
@@ -0,0 +1,297 @@
// catalog_test.go 验证 DNF channel_info.etc 频道目录和首包频道名派生规则。
package channelcatalog
import (
"errors"
"reflect"
"testing"
"longheng.io/server/internal/modules/dnf/channelinfo"
"longheng.io/server/internal/modules/dnf/dnfenum"
)
const latestChannelInfoFixture = `
[dungeon]
` + "`[elven_guard]` `艾尔文防线` 1 2" + `
[/dungeon]
[dungeon]
` + "`[raid]` `团队频道` 200 201" + `
[/dungeon]
[server]
1 1 ` + "`洛兰`" + ` 2 ` + "`[elven_guard]`" + ` 10 0 0 6 ` + "`交易 - 拍卖行`" + ` 3 ` + "`[none]`" + ` 0 0 200 ` + "`团队频道`" + ` 23 ` + "`[raid]`" + ` 0 0 201 ` + "`隐藏团队频道`" + ` 32 ` + "`[raid]`" + ` 0 0
[/server]
[server]
98 1 ` + "`内部测试`" + ` 2 ` + "`[elven_guard]`" + ` 9 0
[/server]
`
func TestNewBuildsGameChannelCatalog(t *testing.T) {
index := parseFixture(t)
catalog, err := New(index, Options{})
if err != nil {
t.Fatalf("New() error = %v", err)
}
channels := catalog.Channels()
if len(channels) != 4 {
t.Fatalf("channels len = %d, want 4: %+v", len(channels), channels)
}
first := channels[0]
if first.ID != 1 || first.Type != 2 || first.Group != "elven_guard" || first.Name != dnfenum.ChannelNamePrefix+"1" || first.Port != dnfenum.GamePortBase+1 || first.MaxUsers != dnfenum.DefaultChannelMaxUsers {
t.Fatalf("first channel mismatch: %+v", first)
}
if !reflect.DeepEqual(first.AreaDungeonIDs, []int{1, 2}) {
t.Fatalf("first channel area dungeon ids = %+v, want [1 2]", first.AreaDungeonIDs)
}
channels[0].AreaDungeonIDs[0] = 999
again, ok := catalog.Channel(1)
if !ok || !reflect.DeepEqual(again.AreaDungeonIDs, []int{1, 2}) {
t.Fatalf("channel area dungeon ids should be cloned, got %+v ok=%v", again.AreaDungeonIDs, ok)
}
if _, ok := catalog.Channel(98); ok {
t.Fatalf("server 98 channel should not enter catalog")
}
if got := catalog.GamePorts(); !reflect.DeepEqual(got, []int{dnfenum.GamePortBase + 1, dnfenum.GamePortBase + 6, dnfenum.GamePortBase + 200, dnfenum.GamePortBase + 201}) {
t.Fatalf("GamePorts() = %+v", got)
}
channel, ok := catalog.ForPort(dnfenum.GamePortBase + 6)
if !ok || channel.ID != 6 || channel.Type != 3 {
t.Fatalf("ForPort(10006) = %+v, %v", channel, ok)
}
}
func TestNewKeepsNoticeNameOnID(t *testing.T) {
const text = "\n[dungeon]\n`[crack]` `crack` 1\n[/dungeon]\n[server]\n1 19 `<4::chn_channel_info_021>` 1 `[crack]` 0 0 6 `<4::chn_channel_info_002>` 3 `[crack]` 0 0\n[/server]\n"
index, err := channelinfo.Parse([]byte(text))
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
catalog, err := New(index, Options{})
if err != nil {
t.Fatalf("New() error = %v", err)
}
channel19, ok := catalog.Channel(19)
if !ok {
t.Fatalf("channel 19 missing")
}
if channel19.Name != dnfenum.ChannelNamePrefix+"19" || channel19.NoticeName != dnfenum.ChannelNamePrefix+"19" || channel19.Port != dnfenum.GamePortBase+19 {
t.Fatalf("channel 19 mismatch: %+v", channel19)
}
channel6, ok := catalog.Channel(6)
if !ok {
t.Fatalf("channel 6 missing")
}
if channel6.Name != dnfenum.ChannelNamePrefix+"6" || channel6.NoticeName != dnfenum.ChannelNamePrefix+"6" || channel6.Port != dnfenum.GamePortBase+6 {
t.Fatalf("channel 6 mismatch: %+v", channel6)
}
}
func TestResidentForKeepsNormalChannelAndReplacesCrackBootstrap(t *testing.T) {
const text = `
[dungeon]
` + "`[crack]` `crack`" + `
[/dungeon]
[dungeon]
` + "`[granfloris]` `granfloris` 1 2" + `
[/dungeon]
[server]
1 19 ` + "`crack`" + ` 1 ` + "`[crack]`" + ` 0 0 10 ` + "`granfloris`" + ` 1 ` + "`[granfloris]`" + ` 0 0 11 ` + "`sky`" + ` 1 ` + "`[granfloris]`" + ` 0 0
[/server]
`
index, err := channelinfo.Parse([]byte(text))
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
catalog, err := New(index, Options{})
if err != nil {
t.Fatalf("New() error = %v", err)
}
crack, _ := catalog.Channel(19)
resident, ok := catalog.ResidentFor(crack)
if !ok || resident.ID != 10 || resident.Group != "granfloris" {
t.Fatalf("resident for crack = %+v, %v", resident, ok)
}
normal, _ := catalog.Channel(11)
resident, ok = catalog.ResidentFor(normal)
if !ok || resident.ID != 11 {
t.Fatalf("resident for normal channel = %+v, %v", resident, ok)
}
}
func TestFilterForRequest(t *testing.T) {
catalog, err := New(parseFixture(t), Options{})
if err != nil {
t.Fatalf("New() error = %v", err)
}
ordinary := catalog.FilterForRequest("cain")
if len(ordinary) != 4 || ordinary[0].ID != 1 || ordinary[1].ID != 6 || ordinary[2].ID != 200 || ordinary[3].ID != 201 {
t.Fatalf("cain channels mismatch: %+v", ordinary)
}
raid := catalog.FilterForRequest("raid")
if len(raid) != 2 || raid[0].ID != 200 || raid[1].ID != 201 {
t.Fatalf("raid channels mismatch: %+v", raid)
}
elven := catalog.FilterForRequest("`[elven_guard]`")
if len(elven) != 1 || elven[0].ID != 1 {
t.Fatalf("elven channels mismatch: %+v", elven)
}
unknown := catalog.FilterForRequest("unknown")
if len(unknown) != 2 || unknown[0].ID != 1 || unknown[1].ID != 6 {
t.Fatalf("unknown group fallback mismatch: %+v", unknown)
}
}
func TestFilterForBootstrapOmitsNativeEntrancesOnly(t *testing.T) {
const text = `
[dungeon]
` + "`[deathtower]` `tower` 1" + `
[/dungeon]
[server]
1 1 ` + "`tower`" + ` 2 ` + "`[deathtower]`" + ` 0 0 6 ` + "`trade`" + ` 3 ` + "`[trade]`" + ` 0 0 19 ` + "`crack`" + ` 1 ` + "`[crack]`" + ` 0 0 31 ` + "`town`" + ` 1 ` + "`[granfloris]`" + ` 0 0
[/server]
`
index, err := channelinfo.Parse([]byte(text))
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
catalog, err := New(index, Options{})
if err != nil {
t.Fatalf("New() error = %v", err)
}
bootstrap := catalog.FilterForBootstrap(dnfenum.GroupCain)
if len(bootstrap) != 2 || bootstrap[0].ID != 19 || bootstrap[1].ID != 31 {
t.Fatalf("bootstrap channels = %+v, want 19/31", bootstrap)
}
full := catalog.FilterForRequest(dnfenum.GroupCain)
if len(full) != 4 {
t.Fatalf("full refresh channels = %d, want 4: %+v", len(full), full)
}
}
func TestFilterUsesSourceTypeInsteadOfLegacyRaidIDs(t *testing.T) {
const text = `
[dungeon]
` + "`[metro]` `metro` 1" + `
[/dungeon]
[dungeon]
` + "`[luke_raid]` `luke` 2" + `
[/dungeon]
[server] 1
201 ` + "`<chn_channel_info_024>`" + ` 11 ` + "`[metro]`" + ` 0 0
241 ` + "`<chn_channel_info_025>`" + ` 32 ` + "`[luke_raid]`" + ` 0 0
[/server]
`
index, err := channelinfo.Parse([]byte(text))
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
catalog, err := New(index, Options{})
if err != nil {
t.Fatalf("New() error = %v", err)
}
ordinary := catalog.FilterForRequest(dnfenum.GroupCain)
if len(ordinary) != 2 || ordinary[0].ID != 201 || ordinary[1].ID != 241 {
t.Fatalf("cain channels = %+v, want source type-11 and type-32 channels", ordinary)
}
raid := catalog.FilterForRequest(dnfenum.GroupRaid)
if len(raid) != 1 || raid[0].ID != 241 {
t.Fatalf("raid channels = %+v, want source type-32 channel 241", raid)
}
}
// TestFiendWarChannelIsRaidAndNeverLeaksIntoOrdinaryLists covers the Fiend War
// owner. The ETC writes its group as `[fiendwar]`, not `raid`, so before the
// type-42 test existed isRaid() was false for it and the channel was treated as
// an ordinary town in both directions: it was left out of the team-channel list
// AND it was eligible for the `!isRaid` fallback that serves a client whose
// requested group matched nothing.
func TestFiendWarChannelIsRaidAndNeverLeaksIntoOrdinaryLists(t *testing.T) {
const text = `
[dungeon]
` + "`[anton_raid]` `anton` 1" + `
[/dungeon]
[dungeon]
` + "`[fiendwar]` `fiend` 2" + `
[/dungeon]
[server] 1
23 ` + "`<chn_channel_info_016>`" + ` 23 ` + "`[anton_raid]`" + ` 0 0
184 ` + "`<chn_channel_info_048>`" + ` 42 ` + "`[fiendwar]`" + ` 0 0
[/server]
`
index, err := channelinfo.Parse([]byte(text))
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
catalog, err := New(index, Options{})
if err != nil {
t.Fatalf("New() error = %v", err)
}
raid := catalog.FilterForRequest(dnfenum.GroupRaid)
if len(raid) != 2 || raid[0].ID != 23 || raid[1].ID != 184 {
t.Fatalf("raid channels = %+v, want the type-23 and type-42 channels", raid)
}
// The unmatched-group fallback is the direction that regressed silently: it
// is what a client receives when its requested group matched nothing, so a
// type-42 channel left out of isRaid() would be offered as an ordinary town.
unknown := catalog.FilterForRequest("unknown")
if len(unknown) != 0 {
t.Fatalf("unknown-group fallback leaked raid channels: %+v", unknown)
}
}
func TestNewPreservesChannelInfoOrder(t *testing.T) {
const text = `
[dungeon]
` + "`[trade]` `交易频道` 1" + `
[/dungeon]
[server]
1 1 ` + "`洛兰`" + ` 2 ` + "`[trade]`" + ` 0 0 38 ` + "`自动频道`" + ` 3 ` + "`[trade]`" + ` 0 0 10 ` + "`洛兰深处`" + ` 1 ` + "`[trade]`" + ` 0 0
[/server]
`
index, err := channelinfo.Parse([]byte(text))
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
catalog, err := New(index, Options{})
if err != nil {
t.Fatalf("New() error = %v", err)
}
channels := catalog.FilterForRequest("cain")
if len(channels) != 3 || channels[0].ID != 1 || channels[1].ID != 38 || channels[2].ID != 10 {
t.Fatalf("channel order mismatch: %+v", channels)
}
}
func TestNewCanSelectConfiguredServer(t *testing.T) {
catalog, err := New(parseFixture(t), Options{ServerID: 98})
if err != nil {
t.Fatalf("New(server 98) error = %v", err)
}
channels := catalog.Channels()
if len(channels) != 1 || channels[0].ServerID != 98 || channels[0].ID != 1 {
t.Fatalf("server 98 channels mismatch: %+v", channels)
}
if got := catalog.GamePorts(); !reflect.DeepEqual(got, []int{dnfenum.GamePortBase + 1}) {
t.Fatalf("server 98 ports = %+v", got)
}
}
func TestNewRejectsEmptyIndex(t *testing.T) {
_, err := New(nil, Options{})
if !errors.Is(err, ErrEmpty) {
t.Fatalf("New(nil) error = %v, want ErrEmpty", err)
}
}
func parseFixture(t *testing.T) *channelinfo.Index {
t.Helper()
index, err := channelinfo.Parse([]byte(latestChannelInfoFixture))
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
return index
}
@@ -0,0 +1,2 @@
// Package channelcatalog 把 DNF channel_info.etc 索引转换成游戏频道目录。
package channelcatalog
@@ -0,0 +1,735 @@
package channelinfo
import (
"bufio"
"bytes"
"errors"
"fmt"
"os"
"sort"
"strconv"
"strings"
"unicode"
"longheng.io/server/internal/modules/dnf/dnfenum"
)
var (
ErrMissing = errors.New("dnf channel info is missing")
ErrInvalid = errors.New("dnf channel info is invalid")
)
// SourceKind 标记 channel_info.etc 的实际来源,便于启动日志和验收证据区分。
type SourceKind string
const (
SourcePVF SourceKind = "pvf"
SourceFile SourceKind = "file"
)
// Source 记录本次加载命中的来源位置。
type Source struct {
Kind SourceKind
Path string
}
// Reader 是 PVF archive 或其他资源包需要实现的最小文本读取接口;未找到路径时应返回 os.ErrNotExist。
type Reader interface {
ReadText(path string) (string, error)
}
// LoadOptions 配置外部活配置路径;Paths 仅供离线 PVF 导出路径复用。
type LoadOptions struct {
Paths []string
FilePath string
}
// Channel 是单条 DNF 频道到区域的路由配置。
type Channel struct {
ServerID int
ID int
NameKey string
Type int
AreaKey string
RawFields []string
}
// Area 是 DNF 区域到副本 ID 的映射。
type Area struct {
Key string
NameKey string
DungeonIDs []int
}
// Index 是启动时构建好的只读查询索引,查询路径不再重复解析 channel_info.etc。
type Index struct {
channels map[channelKey]Channel
channelByID map[int]channelKey
channelOrder []channelKey
areas map[string]Area
areasByDungeon map[int][]string
channelsByDungeon map[int][]channelKey
channelsByServer map[int][]channelKey
}
type channelKey struct {
serverID int
channelID int
}
// DefaultPaths 返回 PVF 中查找 channel_info.etc 的候选路径。
func DefaultPaths() []string {
return []string{
"channel_info.etc",
"channel_info/channel_info.etc",
"etc/channel_info.etc",
"Etc/channel_info.etc",
"script/channel_info.etc",
"Script/channel_info.etc",
"game/channel_info/channel_info.etc",
}
}
// Load 只从外部 channel_info.etc 加载目录;PVF 文本读取请走 ReadPVFText。
func Load(_ Reader, options LoadOptions) (*Index, Source, error) {
if path := strings.TrimSpace(options.FilePath); path != "" {
index, err := LoadFile(path)
if err == nil {
return index, Source{Kind: SourceFile, Path: path}, nil
}
return nil, Source{}, fmt.Errorf("load external channel info %s: %w", path, err)
}
return nil, Source{}, ErrMissing
}
// ReadPVFText 从 PVF 候选路径中读取原始 channel_info.etc 文本,供加载和导出共用。
func ReadPVFText(reader Reader, paths []string) (string, Source, error) {
if len(paths) == 0 {
paths = DefaultPaths()
}
if reader != nil {
for _, path := range paths {
path = strings.TrimSpace(path)
if path == "" {
continue
}
text, err := reader.ReadText(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
continue
}
return "", Source{}, fmt.Errorf("read pvf channel info %s: %w", path, err)
}
return text, Source{Kind: SourcePVF, Path: path}, nil
}
}
return "", Source{}, ErrMissing
}
// LoadFile 从外部文件加载 channel_info.etc;生产侧可把它当作可修改的活配置。
func LoadFile(path string) (*Index, error) {
path = strings.TrimSpace(path)
if path == "" {
return nil, ErrMissing
}
data, err := os.ReadFile(path) //nolint:gosec // G304:路径来自启动配置或测试临时目录,调用方负责限定资源根目录。
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("%w: %s", ErrMissing, path)
}
return nil, err
}
return Parse(data)
}
// Parse 解析 channel_info.etc 文本并一次性构建内存索引。
func Parse(data []byte) (*Index, error) {
parser := newParser()
scanner := bufio.NewScanner(bytes.NewReader(data))
scanner.Buffer(make([]byte, 1024), 1024*1024)
for scanner.Scan() {
parser.line++
if err := parser.accept(cleanLine(scanner.Text())); err != nil {
return nil, err
}
}
if err := scanner.Err(); err != nil {
return nil, err
}
if err := parser.close(); err != nil {
return nil, err
}
return buildIndex(parser.areas, parser.channels, parser.channelOrder)
}
// Channel 按频道 ID 查询路由,返回值已 clone,可被调用方安全修改。
func (i *Index) Channel(id int) (Channel, bool) {
if i == nil {
return Channel{}, false
}
key, ok := i.channelByID[id]
if !ok {
return Channel{}, false
}
channel := i.channels[key]
return cloneChannel(channel), true
}
// ServerChannel 按服务器组和频道 ID 查询精确路由。
func (i *Index) ServerChannel(serverID, channelID int) (Channel, bool) {
if i == nil {
return Channel{}, false
}
channel, ok := i.channels[channelKey{serverID: serverID, channelID: channelID}]
if !ok {
return Channel{}, false
}
return cloneChannel(channel), true
}
// Channels 返回全部频道路由,顺序与外部 channel_info.etc 保持一致。
func (i *Index) Channels() []Channel {
if i == nil {
return nil
}
out := make([]Channel, 0, len(i.channelOrder))
for _, key := range i.channelOrder {
out = append(out, cloneChannel(i.channels[key]))
}
return out
}
// ChannelsForServer 返回指定服务器组的全部频道,顺序与外部 channel_info.etc 保持一致。
func (i *Index) ChannelsForServer(serverID int) []Channel {
if i == nil {
return nil
}
keys := cloneChannelKeys(i.channelsByServer[serverID])
out := make([]Channel, 0, len(keys))
for _, key := range keys {
if channel, ok := i.channels[key]; ok {
out = append(out, cloneChannel(channel))
}
}
return out
}
// Area 按区域 key 查询副本映射,返回值已 clone。
func (i *Index) Area(key string) (Area, bool) {
if i == nil {
return Area{}, false
}
area, ok := i.areas[areaKey(key)]
if !ok {
return Area{}, false
}
return cloneArea(area), true
}
// Areas 返回全部区域映射,按区域 key 升序排列。
func (i *Index) Areas() []Area {
if i == nil {
return nil
}
out := make([]Area, 0, len(i.areas))
for _, area := range i.areas {
out = append(out, cloneArea(area))
}
sort.Slice(out, func(a, b int) bool { return out[a].Key < out[b].Key })
return out
}
// AreasForDungeon 查询副本 ID 所属区域;同一副本出现在多区域时全部返回。
func (i *Index) AreasForDungeon(id int) []string {
if i == nil {
return nil
}
return cloneStrings(i.areasByDungeon[id])
}
// ChannelsForDungeon 查询副本 ID 可进入的频道列表。
func (i *Index) ChannelsForDungeon(id int) []Channel {
if i == nil {
return nil
}
channelIDs := i.channelsByDungeon[id]
out := make([]Channel, 0, len(channelIDs))
for _, key := range channelIDs {
if channel, ok := i.channels[key]; ok {
out = append(out, cloneChannel(channel))
}
}
return out
}
type parser struct {
areas map[string]Area
channels map[channelKey]Channel
channelOrder []channelKey
section string
area Area
serverID int
stage int
line int
}
func newParser() *parser {
return &parser{
areas: make(map[string]Area),
channels: make(map[channelKey]Channel),
serverID: dnfenum.LoginChannelServerIndex,
}
}
func (p *parser) accept(line string) error {
if line == "" {
return nil
}
if serverID, ok, err := parseServerSectionHeader(line); ok {
if err != nil {
return p.fail(err.Error())
}
p.section = "server"
p.serverID = serverID
return nil
}
switch strings.ToLower(line) {
case "[dungeon]":
p.section = "dungeon"
p.area = Area{}
p.stage = 0
return nil
case "[/dungeon]":
return p.endDungeon()
case "[/server]":
p.section = ""
return nil
}
switch p.section {
case "dungeon":
return p.readDungeon(line)
case "server":
return p.readServer(line)
default:
return nil
}
}
func (p *parser) readDungeon(line string) error {
fields := splitFields(line)
if len(fields) == 0 {
return nil
}
if p.stage == 0 {
key := areaKey(fields[0])
if key == "" {
return p.fail("dungeon area key is required")
}
p.area.Key = key
if len(fields) == 1 {
p.stage = 1
return nil
}
p.area.NameKey = textKey(fields[1])
if err := p.appendDungeonIDs(fields[2:]); err != nil {
return err
}
p.stage = 2
return nil
}
if p.stage == 1 {
if isInt(fields[0]) {
p.stage = 2
return p.appendDungeonIDs(fields)
}
p.area.NameKey = textKey(fields[0])
if err := p.appendDungeonIDs(fields[1:]); err != nil {
return err
}
p.stage = 2
return nil
}
return p.appendDungeonIDs(fields)
}
func (p *parser) appendDungeonIDs(fields []string) error {
for _, field := range fields {
id, err := strconv.Atoi(field)
if err != nil {
return p.fail("dungeon id must be integer")
}
if id < 0 {
return p.fail("dungeon id must not be negative")
}
p.area.DungeonIDs = appendInt(p.area.DungeonIDs, id)
}
return nil
}
func (p *parser) readServer(line string) error {
fields := splitFields(line)
if len(fields) == 1 && isInt(fields[0]) {
serverID, err := strconv.Atoi(fields[0])
if err != nil || serverID < 0 {
return p.fail("server id must be non-negative integer")
}
p.serverID = serverID
return nil
}
if len(fields) >= 5 && isInt(fields[0]) && isChannelStart(fields, 1) {
serverID, err := strconv.Atoi(fields[0])
if err != nil || serverID < 0 {
return p.fail("server id must be non-negative integer")
}
p.serverID = serverID
return p.readPackedServer(fields[1:])
}
return p.readServerRow(p.serverID, fields)
}
func (p *parser) readPackedServer(fields []string) error {
for start := 0; start < len(fields); {
if !isChannelStart(fields, start) {
return p.fail("packed server row has invalid channel header")
}
end := len(fields)
for next := start + 4; next < len(fields); next++ {
if isChannelStart(fields, next) {
end = next
break
}
}
if err := p.readServerRow(p.serverID, fields[start:end]); err != nil {
return err
}
start = end
}
return nil
}
func (p *parser) readServerRow(serverID int, fields []string) error {
if len(fields) < 4 {
return p.fail("server row requires channel id, name, type and area")
}
id, err := strconv.Atoi(fields[0])
if err != nil || id < 0 {
return p.fail("channel id must be non-negative integer")
}
channelType, err := strconv.Atoi(fields[2])
if err != nil {
return p.fail("channel type must be integer")
}
channel := Channel{
ServerID: serverID,
ID: id,
NameKey: textKey(fields[1]),
Type: channelType,
AreaKey: areaKey(fields[3]),
RawFields: cloneStrings(fields[4:]),
}
if channel.NameKey == "" || channel.AreaKey == "" {
return p.fail("channel name and area are required")
}
return p.addChannel(channel)
}
func (p *parser) endDungeon() error {
if p.section != "dungeon" || p.area.Key == "" {
return p.fail("dungeon block is incomplete")
}
p.section = ""
p.stage = 0
return p.addArea(p.area)
}
func (p *parser) close() error {
if p.section != "" {
return p.fail("section is not closed")
}
if len(p.areas) == 0 && len(p.channels) == 0 {
return fmt.Errorf("%w: no dungeon or server block", ErrInvalid)
}
return nil
}
func (p *parser) addArea(area Area) error {
if area.Key == "" {
return p.fail("dungeon area key is required")
}
if existing, ok := p.areas[area.Key]; ok {
if existing.NameKey != "" && area.NameKey != "" && existing.NameKey != area.NameKey {
return p.fail("duplicate dungeon area has different name")
}
if existing.NameKey == "" {
existing.NameKey = area.NameKey
}
for _, id := range area.DungeonIDs {
existing.DungeonIDs = appendInt(existing.DungeonIDs, id)
}
p.areas[area.Key] = existing
return nil
}
area.DungeonIDs = cloneInts(area.DungeonIDs)
p.areas[area.Key] = area
return nil
}
func (p *parser) addChannel(channel Channel) error {
key := channelKey{serverID: channel.ServerID, channelID: channel.ID}
if existing, ok := p.channels[key]; ok {
if sameChannel(existing, channel) {
return nil
}
return p.fail("duplicate server channel has different route")
}
p.channels[key] = cloneChannel(channel)
p.channelOrder = append(p.channelOrder, key)
return nil
}
func (p *parser) fail(message string) error {
return fmt.Errorf("%w: line %d: %s", ErrInvalid, p.line, message)
}
func buildIndex(areas map[string]Area, channels map[channelKey]Channel, order []channelKey) (*Index, error) {
if len(areas) == 0 && len(channels) == 0 {
return nil, ErrMissing
}
order = normalizeChannelOrder(order, channels)
index := &Index{
channels: make(map[channelKey]Channel, len(channels)),
channelByID: make(map[int]channelKey, len(channels)),
channelOrder: cloneChannelKeys(order),
areas: make(map[string]Area, len(areas)),
areasByDungeon: make(map[int][]string),
channelsByDungeon: make(map[int][]channelKey),
channelsByServer: make(map[int][]channelKey),
}
for key, area := range areas {
area = cloneArea(area)
index.areas[key] = area
for _, dungeonID := range area.DungeonIDs {
index.areasByDungeon[dungeonID] = appendString(index.areasByDungeon[dungeonID], key)
}
}
areaChannels := make(map[string][]channelKey)
for _, key := range order {
channel := channels[key]
channel = cloneChannel(channel)
index.channels[key] = channel
if _, ok := index.channelByID[channel.ID]; !ok {
index.channelByID[channel.ID] = key
}
index.channelsByServer[channel.ServerID] = appendChannelKey(index.channelsByServer[channel.ServerID], key)
areaChannels[channel.AreaKey] = appendChannelKey(areaChannels[channel.AreaKey], key)
}
for dungeonID, areaKeys := range index.areasByDungeon {
for _, areaKey := range areaKeys {
for _, key := range areaChannels[areaKey] {
index.channelsByDungeon[dungeonID] = appendChannelKey(index.channelsByDungeon[dungeonID], key)
}
}
}
return index, nil
}
func normalizeChannelOrder(order []channelKey, channels map[channelKey]Channel) []channelKey {
out := make([]channelKey, 0, len(channels))
seen := make(map[channelKey]struct{}, len(channels))
for _, key := range order {
if _, ok := channels[key]; !ok {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, key)
}
if len(out) == len(channels) {
return out
}
for _, key := range sortedChannelKeys(channels) {
if _, ok := seen[key]; ok {
continue
}
out = append(out, key)
}
return out
}
func cleanLine(line string) string {
line = strings.TrimPrefix(line, "\ufeff")
for _, mark := range []string{"//", "#", ";"} {
if index := strings.Index(line, mark); index >= 0 {
line = line[:index]
}
}
return strings.TrimSpace(line)
}
func splitFields(line string) []string {
fields := make([]string, 0, 8)
var builder strings.Builder
inBacktick := false
flush := func() {
if builder.Len() == 0 {
return
}
fields = append(fields, builder.String())
builder.Reset()
}
for _, char := range line {
if char == '`' {
inBacktick = !inBacktick
builder.WriteRune(char)
continue
}
if unicode.IsSpace(char) && !inBacktick {
flush()
continue
}
builder.WriteRune(char)
}
flush()
return fields
}
func parseServerSectionHeader(line string) (int, bool, error) {
fields := splitFields(line)
if len(fields) == 0 || !strings.EqualFold(fields[0], "[server]") {
return 0, false, nil
}
if len(fields) == 1 {
return dnfenum.LoginChannelServerIndex, true, nil
}
if len(fields) != 2 {
return 0, true, errors.New("server section header accepts at most one server id")
}
serverID, err := strconv.Atoi(fields[1])
if err != nil || serverID < 0 {
return 0, true, errors.New("server id must be non-negative integer")
}
return serverID, true, nil
}
func textKey(value string) string {
value = strings.TrimSpace(value)
value = strings.Trim(value, "`")
value = strings.TrimPrefix(value, "<")
value = strings.TrimSuffix(value, ">")
value = strings.TrimPrefix(value, "4::")
return strings.TrimSpace(value)
}
func areaKey(value string) string {
value = textKey(value)
value = strings.TrimPrefix(value, "[")
value = strings.TrimSuffix(value, "]")
return strings.ToLower(strings.TrimSpace(value))
}
func isInt(value string) bool {
_, err := strconv.Atoi(value)
return err == nil
}
func isChannelStart(fields []string, index int) bool {
return index >= 0 && index+3 < len(fields) &&
isInt(fields[index]) && isQuoted(fields[index+1]) &&
isInt(fields[index+2]) && isQuoted(fields[index+3])
}
func isQuoted(value string) bool {
return len(value) >= 2 && value[0] == '`' && value[len(value)-1] == '`'
}
func sameChannel(left, right Channel) bool {
return left.ServerID == right.ServerID &&
left.ID == right.ID &&
left.NameKey == right.NameKey &&
left.Type == right.Type &&
left.AreaKey == right.AreaKey &&
sameStrings(left.RawFields, right.RawFields)
}
func sortedChannelKeys(channels map[channelKey]Channel) []channelKey {
keys := make([]channelKey, 0, len(channels))
for key := range channels {
keys = append(keys, key)
}
sortChannelKeys(keys)
return keys
}
func sortChannelKeys(keys []channelKey) {
sort.Slice(keys, func(left, right int) bool {
if keys[left].serverID != keys[right].serverID {
return keys[left].serverID < keys[right].serverID
}
return keys[left].channelID < keys[right].channelID
})
}
func cloneChannelKeys(keys []channelKey) []channelKey {
return append([]channelKey(nil), keys...)
}
func sameStrings(left, right []string) bool {
if len(left) != len(right) {
return false
}
for i := range left {
if left[i] != right[i] {
return false
}
}
return true
}
func cloneChannel(channel Channel) Channel {
channel.RawFields = cloneStrings(channel.RawFields)
return channel
}
func cloneArea(area Area) Area {
area.DungeonIDs = cloneInts(area.DungeonIDs)
return area
}
func cloneStrings(values []string) []string {
return append([]string(nil), values...)
}
func cloneInts(values []int) []int {
return append([]int(nil), values...)
}
func appendString(values []string, value string) []string {
for _, existing := range values {
if existing == value {
return values
}
}
values = append(values, value)
sort.Strings(values)
return values
}
func appendInt(values []int, value int) []int {
for _, existing := range values {
if existing == value {
return values
}
}
return append(values, value)
}
func appendChannelKey(values []channelKey, value channelKey) []channelKey {
for _, existing := range values {
if existing == value {
return values
}
}
return append(values, value)
}
@@ -0,0 +1,292 @@
package channelinfo
import (
"errors"
"os"
"path/filepath"
"testing"
)
const routeFixture = `
[dungeon]
` + "`[granfloris]`" + `
` + "`<4::channel_info_dname_1>`" + `
100
101
[/dungeon]
[server]
1
10 ` + "`<4::chn_channel_info_003>`" + ` 1 ` + "`[granfloris]`" + ` 0 0
6 ` + "`<4::chn_channel_info_002>`" + ` 3 ` + "`[trade]`" + ` 0
[/server]
`
func TestParseBuildsRouteIndex(t *testing.T) {
index, err := Parse([]byte(routeFixture))
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
channel, ok := index.Channel(10)
if !ok {
t.Fatalf("channel 10 should exist")
}
if channel.NameKey != "chn_channel_info_003" || channel.AreaKey != "granfloris" || channel.Type != 1 {
t.Fatalf("channel 10 mismatch: %+v", channel)
}
area, ok := index.Area("`[granfloris]`")
if !ok {
t.Fatalf("area granfloris should exist")
}
if area.NameKey != "channel_info_dname_1" || len(area.DungeonIDs) != 2 {
t.Fatalf("area granfloris mismatch: %+v", area)
}
areas := index.AreasForDungeon(101)
if len(areas) != 1 || areas[0] != "granfloris" {
t.Fatalf("dungeon 101 areas mismatch: %+v", areas)
}
channels := index.ChannelsForDungeon(100)
if len(channels) != 1 || channels[0].ID != 10 {
t.Fatalf("dungeon 100 channels mismatch: %+v", channels)
}
if got := index.ChannelsForDungeon(999); len(got) != 0 {
t.Fatalf("unknown dungeon should return no channel, got %+v", got)
}
serverChannels := index.ChannelsForServer(1)
if len(serverChannels) != 2 || serverChannels[0].ID != 10 || serverChannels[1].ID != 6 {
t.Fatalf("server channel order mismatch: %+v", serverChannels)
}
}
func TestParseProtectsIndexFromCallerMutation(t *testing.T) {
index, err := Parse([]byte(routeFixture))
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
channel, ok := index.Channel(10)
if !ok {
t.Fatalf("channel 10 should exist")
}
channel.RawFields[0] = "mutated"
again, _ := index.Channel(10)
if again.RawFields[0] == "mutated" {
t.Fatalf("channel raw fields should be cloned")
}
area, ok := index.Area("granfloris")
if !ok {
t.Fatalf("area granfloris should exist")
}
area.DungeonIDs[0] = 999
againArea, _ := index.Area("granfloris")
if againArea.DungeonIDs[0] == 999 {
t.Fatalf("area dungeon ids should be cloned")
}
}
func TestParseAcceptsDuplicateServerBlock(t *testing.T) {
const text = `
[dungeon]
[skycastle]
<4::channel_info_dname_2>
200 201
[/dungeon]
[server]
1
11 ` + "`<4::channel_info_cname_11>`" + ` 0 ` + "`[skycastle]`" + ` 5
[/server]
[server]
1
11 ` + "`<4::channel_info_cname_11>`" + ` 0 ` + "`[skycastle]`" + ` 5
[/server]
`
index, err := Parse([]byte(text))
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
channels := index.ChannelsForDungeon(201)
if len(channels) != 1 || channels[0].ID != 11 {
t.Fatalf("dungeon 201 channels mismatch: %+v", channels)
}
}
func TestParseAcceptsInlineServerID(t *testing.T) {
const text = `
[dungeon]
` + "`[granfloris]`" + `
<4::channel_info_dname_1>
3
5
[/dungeon]
[server] 1
10 ` + "`<chn_channel_info_028>`" + ` 11 ` + "`[granfloris]`" + ` 5 0.0 0.0 ` + "``" + `
19 ` + "`<chn_channel_info_028>`" + ` 11 ` + "`[granfloris]`" + ` 5 0 0 ` + "``" + `
[/server]
`
index, err := Parse([]byte(text))
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
channels := index.ChannelsForServer(1)
if len(channels) != 2 || channels[0].ID != 10 || channels[1].ID != 19 {
t.Fatalf("inline server channels mismatch: %+v", channels)
}
if channels[0].NameKey != "chn_channel_info_028" ||
channels[0].AreaKey != "granfloris" ||
channels[0].Type != 11 {
t.Fatalf("inline server channel mismatch: %+v", channels[0])
}
}
func TestParseRejectsInvalidInlineServerID(t *testing.T) {
_, err := Parse([]byte("[server] invalid\n[/server]\n"))
if !errors.Is(err, ErrInvalid) {
t.Fatalf("Parse() error = %v, want ErrInvalid", err)
}
}
func TestParseAcceptsPackedPVFChannelInfo(t *testing.T) {
const text = `
[dungeon]
` + "`[elven_guard]` `艾尔文防线` 1 2" + `
[/dungeon]
[server]
1 1 ` + "`洛兰`" + ` 2 ` + "`[elven_guard]`" + ` 10 0 0 6 ` + "`交易 - 拍卖行`" + ` 3 ` + "`[none]`" + ` 0 0
[/server]
[server]
98 1 ` + "`内部测试`" + ` 2 ` + "`[elven_guard]`" + ` 9 0
[/server]
`
index, err := Parse([]byte(text))
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if channels := index.Channels(); len(channels) != 3 {
t.Fatalf("Channels() len = %d, want 3: %+v", len(channels), channels)
}
channel, ok := index.ServerChannel(1, 1)
if !ok {
t.Fatalf("server 1 channel 1 should exist")
}
if channel.NameKey != "洛兰" || channel.AreaKey != "elven_guard" {
t.Fatalf("server channel mismatch: %+v", channel)
}
if channel, ok := index.ServerChannel(98, 1); !ok || channel.NameKey != "内部测试" {
t.Fatalf("server 98 internal channel should be parsed, got %+v ok=%v", channel, ok)
}
if got := index.ChannelsForServer(98); len(got) != 1 || got[0].ServerID != 98 {
t.Fatalf("server 98 channels mismatch: %+v", got)
}
routed := index.ChannelsForDungeon(1)
if len(routed) != 2 {
t.Fatalf("dungeon 1 routed channels len = %d, want 2: %+v", len(routed), routed)
}
if routed[0].ServerID != 1 || routed[0].ID != 1 {
t.Fatalf("dungeon route should keep server 1 first: %+v", routed)
}
}
func TestParseRejectsConflictingDuplicateChannel(t *testing.T) {
const text = `
[server]
1
11 ` + "`<4::channel_info_cname_11>`" + ` 0 ` + "`[skycastle]`" + `
11 ` + "`<4::channel_info_cname_11>`" + ` 0 ` + "`[granfloris]`" + `
[/server]
`
_, err := Parse([]byte(text))
if !errors.Is(err, ErrInvalid) {
t.Fatalf("Parse() error = %v, want ErrInvalid", err)
}
}
func TestLoadUsesExternalFile(t *testing.T) {
tempDir := t.TempDir()
filePath := filepath.Join(tempDir, "channel_info.etc")
if err := os.WriteFile(filePath, []byte(routeFixture), 0o600); err != nil {
t.Fatalf("write fixture: %v", err)
}
reader := textReader{
"Etc/channel_info.etc": routeFixture,
}
index, source, err := Load(reader, LoadOptions{
Paths: []string{"missing/channel_info.etc", "Etc/channel_info.etc"},
FilePath: filePath,
})
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if source.Kind != SourceFile || source.Path != filePath {
t.Fatalf("source mismatch: %+v", source)
}
if _, ok := index.Channel(10); !ok {
t.Fatalf("channel 10 should exist")
}
}
func TestLoadDoesNotReadPVFWhenExternalFileIsMissing(t *testing.T) {
tempDir := t.TempDir()
filePath := filepath.Join(tempDir, "channel_info.etc")
reader := textReader{"Etc/channel_info.etc": routeFixture}
_, _, err := Load(reader, LoadOptions{
Paths: []string{"missing/channel_info.etc", "Etc/channel_info.etc"},
FilePath: filePath,
})
if !errors.Is(err, ErrMissing) {
t.Fatalf("Load() error = %v, want ErrMissing", err)
}
}
func TestLoadRejectsInvalidExternalFileWithoutPVFFallback(t *testing.T) {
tempDir := t.TempDir()
filePath := filepath.Join(tempDir, "channel_info.etc")
if err := os.WriteFile(filePath, []byte("[server]\n"), 0o600); err != nil {
t.Fatalf("write fixture: %v", err)
}
reader := textReader{"Etc/channel_info.etc": routeFixture}
_, _, err := Load(reader, LoadOptions{
Paths: []string{"Etc/channel_info.etc"},
FilePath: filePath,
})
if !errors.Is(err, ErrInvalid) {
t.Fatalf("Load() error = %v, want ErrInvalid", err)
}
}
func TestParseRejectsEmptyInput(t *testing.T) {
_, err := Parse([]byte(" \n // empty"))
if !errors.Is(err, ErrInvalid) {
t.Fatalf("Parse() error = %v, want ErrInvalid", err)
}
}
func TestLoadRealChannelInfoFileWhenConfigured(t *testing.T) {
path := os.Getenv("DNF_TEST_CHANNEL_INFO_PATH")
if path == "" {
t.Skip("DNF_TEST_CHANNEL_INFO_PATH not set")
}
index, err := LoadFile(path)
if err != nil {
t.Fatalf("LoadFile(%q) error = %v", path, err)
}
if len(index.Areas()) == 0 || len(index.Channels()) == 0 {
t.Fatalf("real channel_info.etc parsed empty index")
}
}
type textReader map[string]string
func (r textReader) ReadText(path string) (string, error) {
text, ok := r[path]
if !ok {
return "", os.ErrNotExist
}
return text, nil
}
@@ -0,0 +1,2 @@
// Package channelinfo 解析 DNF channel_info.etc,并生成频道、区域和副本的内存路由索引。
package channelinfo
@@ -0,0 +1,151 @@
package channelinfo
import (
"bufio"
"bytes"
"errors"
"fmt"
"strconv"
"strings"
)
// OneClickProfile describes the deliberately small channel directory shipped
// with the binary-only one-click release. Non-ordinary and non-arena channel
// groups are retained so special and raid entrances remain available.
type OneClickProfile struct {
ServerID int
OrdinaryChannelID int
OrdinaryAreaKey string
ArenaChannelID int
ArenaAreaKey string
}
// BuildOneClickChannelInfo keeps one ordinary channel and one arena channel
// while preserving every special and team channel from the authoritative ETC.
func BuildOneClickChannelInfo(data []byte, profile OneClickProfile) ([]byte, error) {
if profile.ServerID <= 0 || profile.OrdinaryChannelID <= 0 || profile.ArenaChannelID <= 0 {
return nil, errors.New("one-click channel profile IDs must be positive")
}
ordinaryArea := areaKey(profile.OrdinaryAreaKey)
arenaArea := areaKey(profile.ArenaAreaKey)
if ordinaryArea == "" || arenaArea == "" || ordinaryArea == arenaArea {
return nil, errors.New("one-click ordinary and arena area keys must be distinct")
}
index, err := Parse(data)
if err != nil {
return nil, err
}
channels := index.ChannelsForServer(profile.ServerID)
if len(channels) == 0 {
return nil, fmt.Errorf("one-click source server %d has no channels", profile.ServerID)
}
ordinary, ok := index.ServerChannel(profile.ServerID, profile.OrdinaryChannelID)
if !ok || ordinary.Type != 11 || ordinary.AreaKey != ordinaryArea {
return nil, fmt.Errorf(
"one-click ordinary channel %d must be type 11 in [%s]",
profile.OrdinaryChannelID,
ordinaryArea,
)
}
arena, ok := index.ServerChannel(profile.ServerID, profile.ArenaChannelID)
if !ok || arena.AreaKey != arenaArea {
return nil, fmt.Errorf(
"one-click arena channel %d must be in [%s]",
profile.ArenaChannelID,
arenaArea,
)
}
selected := make([]Channel, 0, len(channels))
for _, channel := range channels {
switch {
case channel.Type == 11:
if channel.ID == profile.OrdinaryChannelID && channel.AreaKey == ordinaryArea {
selected = append(selected, channel)
}
case channel.AreaKey == arenaArea:
if channel.ID == profile.ArenaChannelID {
selected = append(selected, channel)
}
default:
selected = append(selected, channel)
}
}
prefix, err := channelInfoPrefix(data)
if err != nil {
return nil, err
}
var output strings.Builder
output.WriteString(prefix)
output.WriteString("[server] ")
output.WriteString(strconv.Itoa(profile.ServerID))
output.WriteByte('\n')
for _, channel := range selected {
fields := []string{
strconv.Itoa(channel.ID),
"`<" + channel.NameKey + ">`",
strconv.Itoa(channel.Type),
"`[" + channel.AreaKey + "]`",
}
fields = append(fields, normalizedRawFields(channel.RawFields)...)
output.WriteString(" ")
output.WriteString(strings.Join(fields, " "))
output.WriteByte('\n')
}
output.WriteString("[/server]\n")
result := []byte(output.String())
compacted, err := Parse(result)
if err != nil {
return nil, fmt.Errorf("validate one-click channel info: %w", err)
}
ordinaryCount := 0
arenaCount := 0
for _, channel := range compacted.ChannelsForServer(profile.ServerID) {
if channel.Type == 11 {
ordinaryCount++
}
if channel.AreaKey == arenaArea {
arenaCount++
}
}
if ordinaryCount != 1 || arenaCount != 1 {
return nil, fmt.Errorf(
"one-click channel validation failed: ordinary=%d arena=%d",
ordinaryCount,
arenaCount,
)
}
return result, nil
}
func channelInfoPrefix(data []byte) (string, error) {
scanner := bufio.NewScanner(bytes.NewReader(data))
scanner.Buffer(make([]byte, 1024), 1024*1024)
var prefix strings.Builder
for scanner.Scan() {
line := scanner.Text()
if _, ok, err := parseServerSectionHeader(strings.TrimSpace(line)); ok {
if err != nil {
return "", err
}
return prefix.String(), nil
}
prefix.WriteString(line)
prefix.WriteByte('\n')
}
if err := scanner.Err(); err != nil {
return "", err
}
return "", errors.New("one-click channel source has no server section")
}
func normalizedRawFields(fields []string) []string {
out := append([]string(nil), fields...)
for len(out) > 0 && out[len(out)-1] == "``" {
out = out[:len(out)-1]
}
return append(out, "``")
}
@@ -0,0 +1,54 @@
package channelinfo
import "testing"
func TestBuildOneClickChannelInfoKeepsOneOrdinaryAndArenaChannel(t *testing.T) {
data := []byte("[dungeon]\n`[granfloris]` `forest` 1\n[/dungeon]\n" +
"[dungeon]\n`[pvp]` `arena`\n[/dungeon]\n" +
"[server] 1\n" +
"10 `forest` 11 `[granfloris]` 5 0 0 ``\n" +
"19 `forest` 11 `[granfloris]` 5 0 0 ``\n" +
"21 `sky` 11 `[sky_catle]` 5 0 0 ``\n" +
"23 `raid` 23 `[anton_raid]` 5 0 0 ``\n" +
"501 `arena1` 24 `[pvp]` 0 0 0 ``\n" +
"502 `arena2` 24 `[pvp]` 0 0 0 ``\n" +
"[/server]\n")
result, err := BuildOneClickChannelInfo(data, OneClickProfile{
ServerID: 1,
OrdinaryChannelID: 19,
OrdinaryAreaKey: "granfloris",
ArenaChannelID: 501,
ArenaAreaKey: "pvp",
})
if err != nil {
t.Fatal(err)
}
index, err := Parse(result)
if err != nil {
t.Fatal(err)
}
channels := index.ChannelsForServer(1)
if len(channels) != 3 {
t.Fatalf("one-click channels = %+v, want ordinary, raid, and arena", channels)
}
for idx, want := range []int{19, 23, 501} {
if channels[idx].ID != want {
t.Fatalf("one-click channel[%d] = %d, want %d", idx, channels[idx].ID, want)
}
}
}
func TestBuildOneClickChannelInfoRejectsMismatchedRequiredChannel(t *testing.T) {
data := []byte("[server] 1\n19 `forest` 11 `[granfloris]` 0 ``\n501 `arena` 24 `[pvp]` 0 ``\n[/server]\n")
_, err := BuildOneClickChannelInfo(data, OneClickProfile{
ServerID: 1,
OrdinaryChannelID: 10,
OrdinaryAreaKey: "granfloris",
ArenaChannelID: 501,
ArenaAreaKey: "pvp",
})
if err == nil {
t.Fatal("missing required ordinary channel was accepted")
}
}
@@ -0,0 +1,171 @@
package channelinfo
import (
"bufio"
"bytes"
"errors"
"fmt"
"strconv"
"strings"
)
// Build90CNOnlineScript derives the channel table downloaded by the 90CN
// client from the raw PVF channel_info.etc. Source names and groups remain
// authoritative; only the advertised server ID and proved online type
// projection may differ from the raw in-game catalog.
//
// 严格版:源表缺少引导频道时直接失败。只开少数频道(甚至只开特殊区域频道)的运维配置
// 请走 Build90CNOnlineScriptAllowingMissingBootstrap。
func Build90CNOnlineScript(data []byte, sourceServerID, advertiseServerID, bootstrapChannelID int) ([]byte, error) {
script, bootstrapFound, err := build90CNOnlineScript(data, sourceServerID, advertiseServerID, bootstrapChannelID)
if err != nil {
return nil, err
}
if !bootstrapFound {
return nil, fmt.Errorf(
"channel script source server %d is missing bootstrap channel %d",
sourceServerID,
bootstrapChannelID,
)
}
return script, nil
}
// Build90CNOnlineScriptAllowingMissingBootstrap 是 Build90CNOnlineScript 的宽松版:
// 源频道表里没有引导频道时不再失败,而是返回 bootstrapPresent=false,由调用方决定
// 是告警还是拒绝。
//
// 为什么需要它:引导频道这条硬校验来自官方 PVF 流程——那边的引导频道是 id 1 / type 2,
// 由 OnlineTypeFor90CN 映成线上的 type 11;而线上这份 channel_info.etc 里并没有 id 1,
// 于是 dnfenum.BootstrapChannelID 被定成 19,校验就变成了"表里必须有 19"。
// 实测(2026-09-30,1192 次客户端连接)客户端**从不**连 10019:它从 7001 拿目录后
// 直接从目录里挑端口连。所以这条约束对这条链路是化石约束,运维只开任意一个频道时
// 不该把服务端拦在门外。
func Build90CNOnlineScriptAllowingMissingBootstrap(
data []byte,
sourceServerID, advertiseServerID, bootstrapChannelID int,
) ([]byte, bool, error) {
return build90CNOnlineScript(data, sourceServerID, advertiseServerID, bootstrapChannelID)
}
func build90CNOnlineScript(
data []byte,
sourceServerID, advertiseServerID, bootstrapChannelID int,
) ([]byte, bool, error) {
if sourceServerID <= 0 || advertiseServerID < 0 || bootstrapChannelID <= 0 {
return nil, false, errors.New("channel script source, advertisement, and bootstrap IDs are invalid")
}
scanner := bufio.NewScanner(bytes.NewReader(data))
scanner.Buffer(make([]byte, 1024), 1024*1024)
var prefix strings.Builder
serverSectionSeen := false
inServer := false
activeServer := 1
records := make([][]string, 0, 64)
bootstrapFound := false
for lineNumber := 1; scanner.Scan(); lineNumber++ {
line := scanner.Text()
trimmed := strings.TrimSpace(line)
if serverID, ok, err := parseServerSectionHeader(trimmed); ok {
if err != nil {
return nil, false, fmt.Errorf("channel script line %d: %w", lineNumber, err)
}
serverSectionSeen = true
inServer = true
activeServer = serverID
continue
}
switch strings.ToLower(trimmed) {
case "[/server]":
inServer = false
continue
}
if !serverSectionSeen {
prefix.WriteString(line)
prefix.WriteByte('\n')
continue
}
if !inServer {
continue
}
if trimmed == "" {
continue
}
fields := splitFields(trimmed)
if len(fields) == 0 {
continue
}
if len(fields) == 1 {
if id, err := strconv.Atoi(fields[0]); err == nil {
activeServer = id
}
continue
}
start := 0
if len(fields) >= 5 && isInt(fields[0]) && isChannelStart(fields, 1) {
activeServer, _ = strconv.Atoi(fields[0])
start = 1
}
for start < len(fields) {
if !isChannelStart(fields, start) {
return nil, false, fmt.Errorf("channel script line %d: invalid packed channel row", lineNumber)
}
end := len(fields)
for next := start + 4; next < len(fields); next++ {
if isChannelStart(fields, next) {
end = next
break
}
}
if activeServer == sourceServerID {
record := append([]string(nil), fields[start:end]...)
id, _ := strconv.Atoi(record[0])
channelType, _ := strconv.Atoi(record[2])
record[2] = strconv.Itoa(OnlineTypeFor90CN(id, channelType))
if id == bootstrapChannelID {
bootstrapFound = true
}
for len(record) > 0 && record[len(record)-1] == "``" {
record = record[:len(record)-1]
}
record = append(record, "``")
records = append(records, record)
}
start = end
}
}
if err := scanner.Err(); err != nil {
return nil, false, err
}
if len(records) == 0 {
return nil, false, fmt.Errorf("channel script source server %d has no channels", sourceServerID)
}
var output strings.Builder
output.WriteString(prefix.String())
output.WriteString("[server]\n")
output.WriteString(strconv.Itoa(advertiseServerID))
output.WriteByte('\n')
for _, record := range records {
output.WriteString(strings.Join(record, " "))
output.WriteByte('\n')
}
output.WriteString("[/server]\n")
return []byte(output.String()), bootstrapFound, nil
}
// OnlineTypeFor90CN converts a raw PVF channel type into the type stored in
// the downloaded online directory. It must never be used for game CHANNELINFO.
func OnlineTypeFor90CN(channelID, channelType int) int {
if channelType == 0 || channelType == 1 {
return 22
}
if channelID == 1 && channelType == 2 {
return 11
}
return channelType
}
@@ -0,0 +1,213 @@
package channelinfo
import (
"strconv"
"strings"
"testing"
)
func TestBuild90CNOnlineScriptExpandsPackedSourceServer(t *testing.T) {
data := []byte("[dungeon]\n`[sky_catle]` `sky` 11 12\n[/dungeon]\n[server]\n1 1 `tower` 2 `[none]` 0 0 0 6 `trade` 3 `[none]` 0 0 0 10 `forest` 0 `[granfloris]` 5 0 0 19 `crack` 1 `[crack]` 5 0 0 1 0 0 0\n[/server]\n[server]\n2 19 `other` 0 `[sky_catle]` 3 0 0\n[/server]\n")
script, err := Build90CNOnlineScript(data, 1, 0, 19)
if err != nil {
t.Fatal(err)
}
text := string(script)
for _, want := range []string{
"[dungeon]\n`[sky_catle]` `sky` 11 12\n[/dungeon]",
"[server]\n0\n",
"1 `tower` 11 `[none]` 0 0 0 ``\n",
"6 `trade` 3 `[none]` 0 0 0 ``\n",
"10 `forest` 22 `[granfloris]` 5 0 0 ``\n",
"19 `crack` 22 `[crack]` 5 0 0 1 0 0 0 ``\n",
} {
if !strings.Contains(text, want) {
t.Fatalf("online script is missing %q:\n%s", want, text)
}
}
if strings.Contains(text, "2 19 `other`") {
t.Fatalf("online script contains another server:\n%s", text)
}
index, err := Parse(script)
if err != nil {
t.Fatal(err)
}
channels := index.ChannelsForServer(0)
if len(channels) != 4 {
t.Fatalf("online channels = %d, want 4: %+v", len(channels), channels)
}
if channels[0].Type != 11 || channels[1].Type != 3 || channels[2].Type != 22 || channels[3].Type != 22 {
t.Fatalf("online channel types = %d/%d/%d/%d", channels[0].Type, channels[1].Type, channels[2].Type, channels[3].Type)
}
}
func TestBuild90CNOnlineScriptAcceptsInlineServerID(t *testing.T) {
data := []byte("[dungeon]\n`[granfloris]`\n<4::channel_info_dname_1>\n3\n[/dungeon]\n[server] 1\n10 `<chn_channel_info_028>` 11 `[granfloris]` 5 0.0 0.0 ``\n19 `<chn_channel_info_028>` 11 `[granfloris]` 5 0 0 ``\n[/server]\n")
script, err := Build90CNOnlineScript(data, 1, 0, 19)
if err != nil {
t.Fatal(err)
}
index, err := Parse(script)
if err != nil {
t.Fatal(err)
}
channels := index.ChannelsForServer(0)
if len(channels) != 2 || channels[0].ID != 10 || channels[1].ID != 19 {
t.Fatalf("online inline channels mismatch: %+v", channels)
}
if channels[0].NameKey != "chn_channel_info_028" ||
channels[1].NameKey != "chn_channel_info_028" ||
channels[0].AreaKey != "granfloris" ||
channels[1].AreaKey != "granfloris" {
t.Fatalf("online inline source mapping was not preserved: %+v", channels)
}
}
func TestBuild90CNOnlineScriptPreservesSourceMappingForChannel38(t *testing.T) {
data := []byte("[server]\n1\n19 `crack` 1 `[crack]` 5 0 0\n38 `trade` 3 `[trade]` 0 0 0\n[/server]\n")
script, err := Build90CNOnlineScript(data, 1, 0, 19)
if err != nil {
t.Fatal(err)
}
text := string(script)
if !strings.Contains(text, "38 `trade` 3 `[trade]`") {
t.Fatalf("online script did not preserve channel 38 source mapping:\n%s", text)
}
}
func TestBuild90CNOnlineScriptPreservesOriginPvpChannels(t *testing.T) {
data := []byte("[dungeon]\n`[pvp]`\n<4::chn_channel_info_044>\n[/dungeon]\n[server] 1\n" +
"19 `<chn_channel_info_028>` 11 `[granfloris]` 5 0 0 ``\n" +
"501 `<chn_channel_info_039>` 24 `[pvp]` 0 0 0 ``\n" +
"502 `<chn_channel_info_040>` 24 `[pvp]` 0 0 0 ``\n" +
"503 `<chn_channel_info_041>` 24 `[pvp]` 0 0 0 ``\n" +
"504 `<chn_channel_info_042>` 24 `[pvp]` 0 0 0 ``\n" +
"505 `<chn_channel_info_043>` 24 `[pvp]` 0 0 0 ``\n" +
"506 `<chn_channel_info_044>` 24 `[pvp]` 0 0 0 ``\n" +
"507 `<chn_channel_info_045>` 24 `[pvp]` 0 0 0 ``\n" +
"[/server]\n")
script, err := Build90CNOnlineScript(data, 1, 0, 19)
if err != nil {
t.Fatal(err)
}
index, err := Parse(script)
if err != nil {
t.Fatal(err)
}
channels := index.ChannelsForServer(0)
if len(channels) != 8 {
t.Fatalf("online channels = %d, want 8: %+v", len(channels), channels)
}
for i, channel := range channels[1:] {
wantID := 501 + i
wantNameKey := "chn_channel_info_0" + strconv.Itoa(39+i)
if channel.ID != wantID || channel.Type != 24 ||
channel.AreaKey != "pvp" || channel.NameKey != wantNameKey {
t.Fatalf("pvp channel %d mismatch: %+v", wantID, channel)
}
}
}
func TestBuild90CNOnlineScriptNormalizesExistingRecordTerminators(t *testing.T) {
data := []byte("[server]\r\n1\r\n10 `forest` 1 `[granfloris]` 5 0 0 ``\r\n19 `crack` 1 `[crack]` 5 0 0 ``\r\n[/server]\r\n")
script, err := Build90CNOnlineScript(data, 1, 0, 19)
if err != nil {
t.Fatal(err)
}
text := string(script)
if strings.Contains(text, " `` ``\n") {
t.Fatalf("online script contains duplicate record terminators:\n%s", text)
}
if got := strings.Count(text, " ``\n"); got != 2 {
t.Fatalf("online script terminators = %d, want 2:\n%s", got, text)
}
index, err := Parse(script)
if err != nil {
t.Fatal(err)
}
if got := len(index.ChannelsForServer(0)); got != 2 {
t.Fatalf("online channels = %d, want 2", got)
}
}
func TestOnlineTypeFor90CNSeparatesDirectoryTypeFromRawType(t *testing.T) {
tests := []struct {
channelID int
channelType int
want int
}{
{channelID: 10, channelType: 0, want: 22},
{channelID: 31, channelType: 1, want: 22},
{channelID: 1, channelType: 2, want: 11},
{channelID: 6, channelType: 3, want: 3},
{channelID: 51, channelType: 4, want: 4},
}
for _, test := range tests {
if got := OnlineTypeFor90CN(test.channelID, test.channelType); got != test.want {
t.Errorf("OnlineTypeFor90CN(%d, %d) = %d, want %d", test.channelID, test.channelType, got, test.want)
}
}
}
func TestBuild90CNOnlineScriptRequiresBootstrapChannel(t *testing.T) {
_, err := Build90CNOnlineScript([]byte("[server]\n1 10 `forest` 1 `[granfloris]` 0 0\n[/server]\n"), 1, 1, 19)
if err == nil || !strings.Contains(err.Error(), "missing bootstrap channel 19") {
t.Fatalf("error = %v, want missing bootstrap channel", err)
}
}
// TestBuild90CNOnlineScriptAllowingMissingBootstrap 宽松版必须照常产出脚本,
// 并用第二个返回值把"源表没有引导频道"这件事告诉调用方(由它决定告警还是拒绝)。
func TestBuild90CNOnlineScriptAllowingMissingBootstrap(t *testing.T) {
script, bootstrapPresent, err := Build90CNOnlineScriptAllowingMissingBootstrap(
[]byte("[server]\n1 171 `chn_channel_info_014` 22 `[deathtower]` 0 0\n[/server]\n"),
1,
1,
19,
)
if err != nil {
t.Fatalf("宽松模式下不该失败: %v", err)
}
if bootstrapPresent {
t.Fatal("源表里没有 19,bootstrapPresent 必须是 false")
}
online, err := Parse(script)
if err != nil {
t.Fatalf("宽松模式产出的脚本必须仍可解析: %v", err)
}
if _, ok := online.ServerChannel(1, 171); !ok {
t.Fatalf("宽松模式脚本里没有 171:\n%s", script)
}
// 引导频道在表里时必须报告 true,且严格版与宽松版产出一致。
withBootstrap := []byte("[server]\n1 19 `chn_channel_info_028` 11 `[granfloris]` 0 0\n[/server]\n")
relaxedScript, present, err := Build90CNOnlineScriptAllowingMissingBootstrap(withBootstrap, 1, 1, 19)
if err != nil || !present {
t.Fatalf("含引导频道时 present=%v err=%v, want true/nil", present, err)
}
strictScript, err := Build90CNOnlineScript(withBootstrap, 1, 1, 19)
if err != nil {
t.Fatal(err)
}
if string(relaxedScript) != string(strictScript) {
t.Fatalf("宽松版与严格版产出不一致:\n宽松:\n%s\n严格:\n%s", relaxedScript, strictScript)
}
}
func TestBuild90CNOnlineScriptRejectsUnquotedChannelFields(t *testing.T) {
_, err := Build90CNOnlineScript(
[]byte("[server]\n1\n19 crack 1 [crack] 5 0 0\n[/server]\n"),
1,
0,
19,
)
if err == nil || !strings.Contains(err.Error(), "invalid packed channel row") {
t.Fatalf("error = %v, want invalid packed channel row", err)
}
}
@@ -0,0 +1,84 @@
// 本文件定义角色链命令计划。
// dnfbridge 已有登录、选角和 USERINFO 专门链路;这里仅为 aligned fallback 记录意图和 owner 缺口。
package character
import (
"fmt"
"strings"
"longheng.io/server/internal/modules/dnf/alignedcmd"
)
// Command 是 character handler 交给角色 owner 或登录链路的最小命令计划。
type Command struct {
Operation string
AccountID string
SelectedCharacterID uint16
SlotOrCharacterID uint16
Slot uint16
Job byte
Name string
RawLen int
NeedsOwner string
}
// NewSelectCommand 构造选角命令计划。
func NewSelectCommand(req alignedcmd.Request, parsed SelectCharacterRequest) Command {
cmd := newRawCommand(req, "select_character", "login/character owner + op4/op37/scene bootstrap order")
cmd.SlotOrCharacterID = parsed.SlotOrCharacterID
return cmd
}
// NewCreateCommand 构造创角命令计划。
func NewCreateCommand(req alignedcmd.Request, parsed CreateCharacterRequest) Command {
cmd := newRawCommand(req, "create_character", "character owner + slot/name/job validation + roster op2 refresh order")
cmd.Job = parsed.Job
cmd.Name = parsed.Name
return cmd
}
// NewDeleteCommand 构造删角命令计划。
func NewDeleteCommand(req alignedcmd.Request, parsed DeleteCharacterRequest) Command {
cmd := newRawCommand(req, "delete_character", "character owner + delete guard + roster op2 refresh order")
cmd.Slot = parsed.Slot
cmd.Name = parsed.Name
return cmd
}
// NewCheckNameCommand 构造角色名查重命令计划。
func NewCheckNameCommand(req alignedcmd.Request, parsed CheckNameRequest) Command {
cmd := newRawCommand(req, "check_double_character_name", "character name index + current EXE response body evidence")
cmd.Name = parsed.Name
return cmd
}
// NewRawCommand 构造无包体或由专门链路处理的角色命令计划。
func NewRawCommand(req alignedcmd.Request, operation string, needs string) Command {
return newRawCommand(req, operation, needs)
}
func newRawCommand(req alignedcmd.Request, operation string, needs string) Command {
return Command{
Operation: operation,
AccountID: strings.TrimSpace(req.AccountID),
SelectedCharacterID: req.SelectedCharacterID,
RawLen: len(req.Body),
NeedsOwner: needs,
}
}
func (c Command) String() string {
base := fmt.Sprintf("account=%q char=%d", c.AccountID, c.SelectedCharacterID)
switch c.Operation {
case "select_character":
return fmt.Sprintf("%s slotOrChar=%d needs=%s", base, c.SlotOrCharacterID, c.NeedsOwner)
case "create_character":
return fmt.Sprintf("%s job=%d name=%q needs=%s", base, c.Job, c.Name, c.NeedsOwner)
case "delete_character":
return fmt.Sprintf("%s slot=%d name=%q needs=%s", base, c.Slot, c.Name, c.NeedsOwner)
case "check_double_character_name":
return fmt.Sprintf("%s name=%q needs=%s", base, c.Name, c.NeedsOwner)
default:
return fmt.Sprintf("%s rawLen=%d needs=%s", base, c.RawLen, c.NeedsOwner)
}
}
@@ -0,0 +1,126 @@
// 本文件负责角色选择链命令的协议边界。
// dnfbridge 已有专门登录、选角和 USERINFO 链路;这里只补齐 aligned registry 的 character 域解析入口。
package character
import (
"context"
"fmt"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/dnfenum"
)
type handler struct{}
// NewHandler 创建角色协议处理器。
func NewHandler() alignedcmd.Handler {
return handler{}
}
// Domain 返回角色业务域。
func (handler) Domain() dnfenum.AlignedDomain {
return dnfenum.AlignedDomainCharacter
}
// Handle 解析角色相关请求;现有专门链路优先处理,落到这里的命令不直接回成功包。
func (handler) Handle(ctx context.Context, req alignedcmd.Request) (alignedcmd.Result, error) {
switch dnfenum.CmdPacket(req.Opcode) {
case dnfenum.CmdPacketSelectCharacter:
parsed, err := DecodeSelectCharacterRequest(req.Body)
cmd := NewSelectCommand(req, parsed)
return applyOwner(ctx, req, cmd, err), nil
case dnfenum.CmdPacketCreateCharacter:
parsed, err := DecodeCreateCharacterRequest(req.Body)
cmd := NewCreateCommand(req, parsed)
return applyOwner(ctx, req, cmd, err), nil
case dnfenum.CmdPacketDeleteCharacter:
parsed, err := DecodeDeleteCharacterRequest(req.Body)
cmd := NewDeleteCommand(req, parsed)
return applyOwner(ctx, req, cmd, err), nil
case dnfenum.CmdPacketReturnSelectCharacter:
cmd := NewRawCommand(req, "return_select_character", "login chain owner + roster op2 refresh order")
return applyOwner(ctx, req, cmd, nil), nil
case dnfenum.CmdPacketGetUserinfo:
cmd := NewRawCommand(req, "get_userinfo", "USERINFO builder + current EXE subtype order + scene bootstrap gate")
return applyOwner(ctx, req, cmd, nil), nil
case dnfenum.CmdPacketCheckDoubleCharacterName:
parsed, err := DecodeCheckNameRequest(req.Body)
cmd := NewCheckNameCommand(req, parsed)
return applyOwner(ctx, req, cmd, err), nil
default:
return alignedcmd.Result{
Handled: false,
ResponseAllowed: false,
Operation: "unregistered",
Reason: fmt.Sprintf("character 模块未登记 opcode %d", req.Opcode),
}, nil
}
}
func applyOwner(ctx context.Context, req alignedcmd.Request, cmd Command, parseErr error) alignedcmd.Result {
if parseErr != nil {
return blockedParsedResult(cmd.Operation, cmd.String(), parseErr)
}
owner, err := NewOwner(req.Repositories)
if err != nil {
return ownerBlockedResult(cmd, err)
}
result, err := owner.Plan(ctx, cmd)
if err != nil {
return ownerBlockedResult(cmd, planError(cmd.Operation, err))
}
return ownerPlannedResult(cmd, result)
}
func blockedParsedResult(operation string, summary string, err error) alignedcmd.Result {
if err != nil {
return alignedcmd.Result{
Handled: true,
ResponseAllowed: false,
Operation: operation,
Reason: fmt.Sprintf("%s 请求体解析失败:%v;禁止回成功包", operation, err),
}
}
return alignedcmd.Result{
Handled: true,
ResponseAllowed: false,
Operation: operation,
Reason: fmt.Sprintf("已解析 %s:%s;角色链现在由 dnfbridge 专门链路处理,aligned fallback 不直接回包", operation, summary),
}
}
func ownerBlockedResult(cmd Command, err error) alignedcmd.Result {
return alignedcmd.Result{
Handled: true,
ResponseAllowed: false,
Operation: cmd.Operation,
Reason: fmt.Sprintf("已解析 %s:%s;character owner 预检未通过:%v;aligned fallback 禁止成功 ACK", cmd.Operation, cmd.String(), err),
}
}
func ownerPlannedResult(cmd Command, result PlanResult) alignedcmd.Result {
return alignedcmd.Result{
Handled: true,
ResponseAllowed: false,
Operation: cmd.Operation,
Reason: fmt.Sprintf(
"已解析 %s:%s;character owner verified account=%s char=%s known=%t name=%q slot=%d job=%q level=%d roster=%d requested=(slotOrChar=%d slot=%d job=%d name=%q) nameTaken=%t owner=%s;角色列表/USERINFO/进场专门链路未闭合,aligned fallback 禁止成功 ACK",
cmd.Operation,
cmd.String(),
result.AccountID,
result.CharacterID,
result.CharacterKnown,
result.Name,
result.Slot,
result.Job,
result.Level,
result.RosterCount,
result.SelectedOrRequested,
result.RequestedSlot,
result.RequestedJob,
result.RequestedName,
result.NameTaken,
result.NameOwnerID,
),
}
}
@@ -0,0 +1,100 @@
package character
import (
"context"
"strings"
"testing"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestDecodeCreateCharacterRequest(t *testing.T) {
got, err := DecodeCreateCharacterRequest([]byte{15, 3, 0, 0, 0, 'a', 'b', 'c', 0})
if err != nil {
t.Fatalf("DecodeCreateCharacterRequest error = %v", err)
}
if got.Job != 15 || got.Name != "abc" {
t.Fatalf("got = %+v", got)
}
}
func TestDecodeDeleteCharacterRequest(t *testing.T) {
got, err := DecodeDeleteCharacterRequest([]byte{2, 3, 0, 0, 0, 'a', 'b', 'c'})
if err != nil {
t.Fatalf("DecodeDeleteCharacterRequest error = %v", err)
}
if got.Slot != 2 || got.Name != "abc" {
t.Fatalf("got = %+v", got)
}
}
func TestDecodeCurrentDeleteCharacterRequestUsesUint16Slot(t *testing.T) {
got, err := DecodeDeleteCharacterRequest([]byte{2, 0, 3, 0, 0, 0, 'a', 'b', 'c'})
if err != nil {
t.Fatalf("DecodeDeleteCharacterRequest error = %v", err)
}
if got.Slot != 2 || got.Name != "abc" {
t.Fatalf("got = %+v", got)
}
}
func TestHandlerBlocksCharacterFallbackAck(t *testing.T) {
got, err := NewHandler().Handle(context.Background(), alignedcmd.Request{
Opcode: uint16(dnfenum.CmdPacketReturnSelectCharacter),
})
if err != nil {
t.Fatalf("Handle error = %v", err)
}
if !got.Handled || got.ResponseAllowed {
t.Fatalf("result = %+v, want handled without response", got)
}
}
func TestHandlerUsesOwnerPreflightAndStillBlocksCharacterAck(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "77",
AccountID: "acc",
Slot: 2,
Name: "hero",
Job: "15",
Level: 86,
}); err != nil {
t.Fatalf("Character.Save error = %v", err)
}
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
Opcode: uint16(dnfenum.CmdPacketCheckDoubleCharacterName),
AccountID: "acc",
Repositories: repos,
Body: []byte{4, 0, 0, 0, 'h', 'e', 'r', 'o'},
})
if err != nil {
t.Fatalf("Handle error = %v", err)
}
if !got.Handled || got.ResponseAllowed {
t.Fatalf("result = %+v, want handled without response", got)
}
for _, want := range []string{"character owner verified", "roster=1", `requested=(slotOrChar=0 slot=0 job=0 name="hero")`, "nameTaken=true", "owner=77"} {
if !strings.Contains(got.Reason, want) {
t.Fatalf("reason %q missing %q", got.Reason, want)
}
}
}
func TestCreateCommandRecordsCharacterOwnerGap(t *testing.T) {
cmd := NewCreateCommand(alignedcmd.Request{
AccountID: " acc ",
SelectedCharacterID: 0,
}, CreateCharacterRequest{Job: 15, Name: "abc"})
summary := cmd.String()
for _, want := range []string{`account="acc"`, "job=15", `name="abc"`, "character owner", "roster op2"} {
if !strings.Contains(summary, want) {
t.Fatalf("summary %q missing %q", summary, want)
}
}
}
@@ -0,0 +1,187 @@
// 本文件提供角色链 fallback 的只读 owner 预检。
// 登录、选角、USERINFO 和角色列表仍由 dnfbridge 专门链路闭合,这里不写角色也不开放成功 ACK。
package character
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
var (
ErrOwnerUnavailable = errors.New("character owner unavailable")
ErrCharacterRequired = errors.New("selected character id required")
ErrCharacterNotFound = errors.New("character record not found")
ErrAccountMismatch = errors.New("character account mismatch")
)
// Owner 是角色链 fallback 的只读查询边界。
type Owner struct {
characters dnfrepo.CharacterRepository
}
// PlanResult 描述角色请求当前能从角色仓储确认到的上下文。
type PlanResult struct {
AccountID string
CharacterID string
CharacterKnown bool
Name string
Slot int
Job string
Level int
RosterCount int
NameTaken bool
NameOwnerID string
RequestedName string
RequestedJob byte
RequestedSlot int
SelectedOrRequested uint16
}
// NewOwner 创建角色 owner;缺少角色仓储时拒绝预检。
func NewOwner(repos dnfrepo.Group) (*Owner, error) {
if repos.Character == nil {
return nil, ErrOwnerUnavailable
}
return &Owner{characters: repos.Character}, nil
}
// Plan 只读取角色名索引、账号角色列表或当前角色快照,不承担真实登录/选角回包。
func (o *Owner) Plan(ctx context.Context, cmd Command) (PlanResult, error) {
if o == nil || o.characters == nil {
return PlanResult{}, ErrOwnerUnavailable
}
result := PlanResult{
AccountID: strings.TrimSpace(cmd.AccountID),
RequestedName: strings.TrimSpace(cmd.Name),
RequestedJob: cmd.Job,
RequestedSlot: int(cmd.Slot),
SelectedOrRequested: cmd.SlotOrCharacterID,
}
if result.AccountID != "" {
roster, err := o.characters.ListByAccount(ctx, result.AccountID, 32)
if err != nil {
return PlanResult{}, err
}
result.RosterCount = len(roster)
}
switch cmd.Operation {
case "select_character":
record, ok, err := o.findSelected(ctx, cmd)
if err != nil {
return PlanResult{}, err
}
if !ok {
return PlanResult{}, ErrCharacterNotFound
}
fillCharacterResult(&result, record)
case "return_select_character":
// 返回角色选择只需要账号角色列表上下文,真实 op2 刷新仍由专门链路生成。
case "get_userinfo":
record, ok, err := o.findSelected(ctx, cmd)
if err != nil {
return PlanResult{}, err
}
if !ok {
return PlanResult{}, ErrCharacterNotFound
}
fillCharacterResult(&result, record)
case "create_character", "check_double_character_name":
if result.RequestedName != "" {
id, taken, err := o.characters.FindIDByName(ctx, result.RequestedName)
if err != nil {
return PlanResult{}, err
}
result.NameTaken = taken
result.NameOwnerID = id
}
case "delete_character":
if result.RequestedName != "" {
id, taken, err := o.characters.FindIDByName(ctx, result.RequestedName)
if err != nil {
return PlanResult{}, err
}
result.NameTaken = taken
result.NameOwnerID = id
if taken {
record, ok, err := o.characters.Load(ctx, id)
if err != nil {
return PlanResult{}, err
}
if ok {
fillCharacterResult(&result, record)
}
}
}
}
return result, nil
}
func (o *Owner) findSelected(ctx context.Context, cmd Command) (dnfrepo.CharacterRecord, bool, error) {
accountID := strings.TrimSpace(cmd.AccountID)
if cmd.SelectedCharacterID != 0 {
record, ok, err := o.loadByNumericID(ctx, cmd.SelectedCharacterID)
if err != nil || !ok {
return record, ok, err
}
if !matchesAccount(record, accountID) {
return dnfrepo.CharacterRecord{}, false, ErrAccountMismatch
}
return record, true, nil
}
if cmd.SlotOrCharacterID != 0 {
if record, ok, err := o.loadByNumericID(ctx, cmd.SlotOrCharacterID); err != nil || (ok && matchesAccount(record, accountID)) {
return record, ok, err
}
}
if accountID == "" {
return dnfrepo.CharacterRecord{}, false, ErrCharacterRequired
}
roster, err := o.characters.ListByAccount(ctx, accountID, 32)
if err != nil {
return dnfrepo.CharacterRecord{}, false, err
}
requestedSlot := int(cmd.SlotOrCharacterID)
for _, record := range roster {
if record.Slot == requestedSlot {
return dnfrepo.CloneCharacter(record), true, nil
}
}
return dnfrepo.CharacterRecord{}, false, nil
}
func (o *Owner) loadByNumericID(ctx context.Context, id uint16) (dnfrepo.CharacterRecord, bool, error) {
key := strconv.FormatUint(uint64(id), 10)
record, ok, err := o.characters.Load(ctx, key)
if err != nil || !ok {
return dnfrepo.CharacterRecord{}, ok, err
}
return dnfrepo.CloneCharacter(record), true, nil
}
func matchesAccount(record dnfrepo.CharacterRecord, accountID string) bool {
return accountID == "" || strings.TrimSpace(record.AccountID) == accountID
}
func fillCharacterResult(result *PlanResult, record dnfrepo.CharacterRecord) {
record = dnfrepo.CloneCharacter(record)
result.CharacterID = record.CharacterID
result.CharacterKnown = true
result.AccountID = record.AccountID
result.Name = record.Name
result.Slot = record.Slot
result.Job = record.Job
result.Level = record.Level
}
func planError(operation string, err error) error {
if err == nil {
return nil
}
return fmt.Errorf("%s: %w", strings.TrimSpace(operation), err)
}
@@ -0,0 +1,109 @@
package character
import (
"context"
"testing"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestOwnerPlansSelectCharacterByAccountSlot(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "77",
AccountID: "acc",
Slot: 2,
Name: "hero",
Job: "15",
Level: 86,
}); err != nil {
t.Fatalf("Character.Save error = %v", err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner error = %v", err)
}
got, err := owner.Plan(ctx, Command{
Operation: "select_character",
AccountID: "acc",
SlotOrCharacterID: 2,
})
if err != nil {
t.Fatalf("Plan error = %v", err)
}
if !got.CharacterKnown || got.CharacterID != "77" || got.Name != "hero" || got.Slot != 2 || got.Level != 86 {
t.Fatalf("result = %+v", got)
}
if got.RosterCount != 1 {
t.Fatalf("roster count = %d", got.RosterCount)
}
}
func TestOwnerSelectCharacterDoesNotCrossAccountOnSlotOrIDCollision(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "2",
AccountID: "other",
Slot: 9,
Name: "wrong",
}); err != nil {
t.Fatalf("Character.Save(other) error = %v", err)
}
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "77",
AccountID: "acc",
Slot: 2,
Name: "hero",
}); err != nil {
t.Fatalf("Character.Save(acc) error = %v", err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner error = %v", err)
}
got, err := owner.Plan(ctx, Command{
Operation: "select_character",
AccountID: "acc",
SlotOrCharacterID: 2,
})
if err != nil {
t.Fatalf("Plan error = %v", err)
}
if got.CharacterID != "77" || got.Name != "hero" {
t.Fatalf("result = %+v, want account slot match", got)
}
}
func TestOwnerPlansNameTaken(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "77",
AccountID: "acc",
Slot: 2,
Name: "hero",
}); err != nil {
t.Fatalf("Character.Save error = %v", err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner error = %v", err)
}
got, err := owner.Plan(ctx, Command{
Operation: "check_double_character_name",
AccountID: "acc",
Name: "hero",
})
if err != nil {
t.Fatalf("Plan error = %v", err)
}
if !got.NameTaken || got.NameOwnerID != "77" || got.RosterCount != 1 {
t.Fatalf("result = %+v", got)
}
}
@@ -0,0 +1,104 @@
// 本文件解析角色选择链 C2S 请求体。
// 字段顺序参考 C# CharacterSelectHandler;当前只用于 fallback 日志和后续 owner 化计划。
package character
import (
"encoding/binary"
"fmt"
)
// SelectCharacterRequest 描述选择角色请求。
type SelectCharacterRequest struct {
SlotOrCharacterID uint16
}
// CreateCharacterRequest 描述创建角色请求。
type CreateCharacterRequest struct {
Job byte
Name string
}
// DeleteCharacterRequest 描述删除角色请求。
type DeleteCharacterRequest struct {
Slot uint16
Name string
}
// CheckNameRequest 描述角色名查重请求。
type CheckNameRequest struct {
Name string
}
// DecodeSelectCharacterRequest 解析 C# 选角请求的前 2 字节槽位。
func DecodeSelectCharacterRequest(body []byte) (SelectCharacterRequest, error) {
if len(body) < 2 {
return SelectCharacterRequest{}, fmt.Errorf("body too short: got %d want >= 2", len(body))
}
return SelectCharacterRequest{SlotOrCharacterID: binary.LittleEndian.Uint16(body)}, nil
}
// DecodeCreateCharacterRequest 解析创建角色请求:u8 job + i32 nameLen + name bytes。
func DecodeCreateCharacterRequest(body []byte) (CreateCharacterRequest, error) {
if len(body) < 6 {
return CreateCharacterRequest{}, fmt.Errorf("body too short: got %d want >= 6", len(body))
}
nameLen := int(int32(binary.LittleEndian.Uint32(body[1:])))
if nameLen < 0 || 5+nameLen+1 > len(body) {
return CreateCharacterRequest{}, fmt.Errorf("name length %d invalid for body %d", nameLen, len(body))
}
return CreateCharacterRequest{
Job: body[0],
Name: string(body[5 : 5+nameLen]),
}, nil
}
// DecodeDeleteCharacterRequest parses the current u16 slot + i32 nameLen +
// ANSI name layout. The historical u8-slot variant remains accepted.
func DecodeDeleteCharacterRequest(body []byte) (DeleteCharacterRequest, error) {
if len(body) < 6 {
return DeleteCharacterRequest{}, fmt.Errorf("body too short: got %d want >= 6", len(body))
}
nameLen := int(int32(binary.LittleEndian.Uint32(body[2:])))
if nameLen > 0 && 6+nameLen <= len(body) {
return DeleteCharacterRequest{
Slot: binary.LittleEndian.Uint16(body[:2]),
Name: string(body[6 : 6+nameLen]),
}, nil
}
nameLen = int(int32(binary.LittleEndian.Uint32(body[1:])))
if nameLen <= 0 || 5+nameLen > len(body) {
return DeleteCharacterRequest{}, fmt.Errorf("name length %d invalid for body %d", nameLen, len(body))
}
return DeleteCharacterRequest{
Slot: uint16(body[0]),
Name: string(body[5 : 5+nameLen]),
}, nil
}
// DecodeCheckNameRequest 解析角色名查重请求:i32 nameLen + name bytes。
func DecodeCheckNameRequest(body []byte) (CheckNameRequest, error) {
if len(body) < 5 {
return CheckNameRequest{}, fmt.Errorf("body too short: got %d want >= 5", len(body))
}
nameLen := int(int32(binary.LittleEndian.Uint32(body)))
if nameLen <= 0 || 4+nameLen > len(body) {
return CheckNameRequest{}, fmt.Errorf("name length %d invalid for body %d", nameLen, len(body))
}
return CheckNameRequest{Name: string(body[4 : 4+nameLen])}, nil
}
func (r SelectCharacterRequest) String() string {
return fmt.Sprintf("slotOrChar=%d", r.SlotOrCharacterID)
}
func (r CreateCharacterRequest) String() string {
return fmt.Sprintf("job=%d name=%q", r.Job, r.Name)
}
func (r DeleteCharacterRequest) String() string {
return fmt.Sprintf("slot=%d name=%q", r.Slot, r.Name)
}
func (r CheckNameRequest) String() string {
return fmt.Sprintf("name=%q", r.Name)
}
@@ -0,0 +1,176 @@
// Package characterdata owns protocol-independent character state assembly.
// Packet builders may consume an Aggregate, but wire layouts do not belong here.
package characterdata
import (
"context"
"errors"
"fmt"
"strings"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
var (
ErrCharacterIDRequired = errors.New("character id is required")
ErrCharacterNotFound = errors.New("character not found")
ErrRepositoryUnavailable = errors.New("character data repository is unavailable")
)
// Parts selects the durable child records to load with a character.
type Parts uint32
const (
PartInventory Parts = 1 << iota
PartEquipment
PartSkill
PartQuest
PartPet
AllParts = PartInventory | PartEquipment | PartSkill | PartQuest | PartPet
)
// Presence distinguishes a missing child record from a persisted empty record.
type Presence struct {
Inventory bool
Equipment bool
Skill bool
Quest bool
Pet bool
}
// Aggregate is a detached snapshot of one character and its requested durable
// child records. Generic settings are intentionally excluded until their
// character scopes become typed repositories.
type Aggregate struct {
Character dnfrepo.CharacterRecord
Inventory dnfrepo.InventoryRecord
Equipment dnfrepo.EquipmentRecord
Skill dnfrepo.SkillRecord
Quest dnfrepo.QuestRecord
Pet dnfrepo.PetRecord
Presence Presence
}
// Loader assembles character state through repository interfaces without
// depending on dnfbridge sessions or packet formats.
type Loader struct {
repositories dnfrepo.Group
}
func NewLoader(repositories dnfrepo.Group) *Loader {
return &Loader{repositories: repositories}
}
// Load reads the character base record and requested child records.
func (l *Loader) Load(ctx context.Context, characterID string, parts Parts) (Aggregate, bool, error) {
characterID = strings.TrimSpace(characterID)
if characterID == "" {
return Aggregate{}, false, ErrCharacterIDRequired
}
if l == nil || l.repositories.Character == nil {
return Aggregate{}, false, repositoryError("character")
}
character, found, err := l.repositories.Character.Load(ctxOrBackground(ctx), characterID)
if err != nil || !found {
return Aggregate{}, found, err
}
aggregate, err := l.Hydrate(ctx, character, parts)
if err != nil {
return Aggregate{}, false, err
}
return aggregate, true, nil
}
// Hydrate attaches requested child records to an already loaded character.
func (l *Loader) Hydrate(ctx context.Context, character dnfrepo.CharacterRecord, parts Parts) (Aggregate, error) {
character = dnfrepo.CloneCharacter(character)
characterID := strings.TrimSpace(character.CharacterID)
if characterID == "" {
return Aggregate{}, ErrCharacterIDRequired
}
if l == nil {
return Aggregate{}, repositoryError("group")
}
ctx = ctxOrBackground(ctx)
aggregate := Aggregate{Character: character}
var err error
if parts&PartInventory != 0 {
if l.repositories.Inventory == nil {
return Aggregate{}, repositoryError("inventory")
}
aggregate.Inventory, aggregate.Presence.Inventory, err = l.repositories.Inventory.Load(ctx, characterID)
if err != nil {
return Aggregate{}, fmt.Errorf("load character inventory: %w", err)
}
aggregate.Inventory = dnfrepo.CloneInventory(aggregate.Inventory)
if !aggregate.Presence.Inventory {
aggregate.Inventory.CharacterID = characterID
}
}
if parts&PartEquipment != 0 {
if l.repositories.Equipment == nil {
return Aggregate{}, repositoryError("equipment")
}
aggregate.Equipment, aggregate.Presence.Equipment, err = l.repositories.Equipment.Load(ctx, characterID)
if err != nil {
return Aggregate{}, fmt.Errorf("load character equipment: %w", err)
}
aggregate.Equipment = dnfrepo.CloneEquipment(aggregate.Equipment)
if !aggregate.Presence.Equipment {
aggregate.Equipment.CharacterID = characterID
}
}
if parts&PartSkill != 0 {
if l.repositories.Skill == nil {
return Aggregate{}, repositoryError("skill")
}
aggregate.Skill, aggregate.Presence.Skill, err = l.repositories.Skill.Load(ctx, characterID)
if err != nil {
return Aggregate{}, fmt.Errorf("load character skill: %w", err)
}
aggregate.Skill = dnfrepo.CloneSkill(aggregate.Skill)
if !aggregate.Presence.Skill {
aggregate.Skill.CharacterID = characterID
}
}
if parts&PartQuest != 0 {
if l.repositories.Quest == nil {
return Aggregate{}, repositoryError("quest")
}
aggregate.Quest, aggregate.Presence.Quest, err = l.repositories.Quest.Load(ctx, characterID)
if err != nil {
return Aggregate{}, fmt.Errorf("load character quest: %w", err)
}
aggregate.Quest = dnfrepo.CloneQuest(aggregate.Quest)
if !aggregate.Presence.Quest {
aggregate.Quest.CharacterID = characterID
}
}
if parts&PartPet != 0 {
if l.repositories.Pet == nil {
return Aggregate{}, repositoryError("pet")
}
aggregate.Pet, aggregate.Presence.Pet, err = l.repositories.Pet.Load(ctx, characterID)
if err != nil {
return Aggregate{}, fmt.Errorf("load character pet: %w", err)
}
aggregate.Pet = dnfrepo.ClonePet(aggregate.Pet)
if !aggregate.Presence.Pet {
aggregate.Pet.CharacterID = characterID
}
}
return aggregate, nil
}
func repositoryError(name string) error {
return fmt.Errorf("%w: %s", ErrRepositoryUnavailable, name)
}
func ctxOrBackground(ctx context.Context) context.Context {
if ctx == nil {
return context.Background()
}
return ctx
}
@@ -0,0 +1,162 @@
package characterdata
import (
"context"
"errors"
"testing"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestLoaderLoadsRequestedCharacterParts(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
character := dnfrepo.CharacterRecord{
CharacterID: "17",
AccountID: "account-1",
Name: "hero",
Level: 1,
Stats: map[string]int64{"strength": 21},
}
if err := repositories.Character.Save(ctx, character); err != nil {
t.Fatal(err)
}
if err := repositories.Equipment.Save(ctx, dnfrepo.EquipmentRecord{
CharacterID: "17",
Entries: map[string]dnfrepo.EquipmentEntry{
"11": {SlotIndex: 11, ItemID: 1001, RawEntry: []byte{1, 2, 3}},
},
}); err != nil {
t.Fatal(err)
}
aggregate, found, err := NewLoader(repositories).Load(ctx, "17", PartEquipment|PartSkill)
if err != nil || !found {
t.Fatalf("Load() found=%v err=%v", found, err)
}
if !aggregate.Presence.Equipment || aggregate.Equipment.Entries["11"].ItemID != 1001 {
t.Fatalf("equipment not loaded: %+v", aggregate)
}
if aggregate.Presence.Skill || aggregate.Skill.CharacterID != "17" {
t.Fatalf("missing skill presence not preserved: %+v", aggregate)
}
character.Stats["strength"] = 999
entry := aggregate.Equipment.Entries["11"]
entry.RawEntry[0] = 9
stored, _, err := repositories.Equipment.Load(ctx, "17")
if err != nil {
t.Fatal(err)
}
if stored.Entries["11"].RawEntry[0] != 1 {
t.Fatalf("aggregate shares mutable equipment bytes: %v", stored.Entries["11"].RawEntry)
}
}
func TestCreatorPersistsCompleteInitialization(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
now := time.Unix(100, 0).UTC()
inventory := dnfrepo.InventoryRecord{Slots: map[string]dnfrepo.ItemStack{"0:1": {ItemID: 1}}}
equipment := dnfrepo.EquipmentRecord{Entries: map[string]dnfrepo.EquipmentEntry{"11": {SlotIndex: 11, ItemID: 1001}}}
skill := dnfrepo.SkillRecord{Skills: map[int64]dnfrepo.SkillState{10: {Level: 1, Enabled: true}}}
err := NewCreator(repositories).Create(ctx, Creation{
Account: &dnfrepo.AccountRecord{AccountID: "account-1", State: "active"},
Character: dnfrepo.CharacterRecord{
CharacterID: "21",
AccountID: "account-1",
Slot: 0,
Name: "hero",
Level: 1,
CreatedAt: now,
UpdatedAt: now,
},
Inventory: &inventory,
Equipment: &equipment,
Skill: &skill,
Settings: []dnfrepo.SettingsRecord{{
Scope: dnfrepo.CharacterContainerStateScope("21"),
Values: map[string]string{"main_slots": "24"},
}},
})
if err != nil {
t.Fatal(err)
}
if _, found, err := repositories.Character.Load(ctx, "21"); err != nil || !found {
t.Fatalf("character found=%v err=%v", found, err)
}
for name, load := range map[string]func() (bool, error){
"inventory": func() (bool, error) { _, found, err := repositories.Inventory.Load(ctx, "21"); return found, err },
"equipment": func() (bool, error) { _, found, err := repositories.Equipment.Load(ctx, "21"); return found, err },
"skill": func() (bool, error) { _, found, err := repositories.Skill.Load(ctx, "21"); return found, err },
"settings": func() (bool, error) {
_, found, err := repositories.Settings.Load(ctx, dnfrepo.CharacterContainerStateScope("21"))
return found, err
},
} {
found, err := load()
if err != nil || !found {
t.Fatalf("%s found=%v err=%v", name, found, err)
}
}
}
func TestCreatorPreservesExistingAccountHonorExperience(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
createdAt := time.Unix(50, 0).UTC()
if err := repositories.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "account-1",
State: "existing",
HonorExp: 17699999999,
Metadata: map[string]string{"owner": "existing"},
CreatedAt: createdAt,
}); err != nil {
t.Fatal(err)
}
err := NewCreator(repositories).Create(ctx, Creation{
Account: &dnfrepo.AccountRecord{
AccountID: "account-1",
State: "active",
HonorExp: 0,
Metadata: map[string]string{"source": "dnfbridge"},
},
Character: dnfrepo.CharacterRecord{
CharacterID: "22",
AccountID: "account-1",
Name: "Test",
Slot: 1,
Level: 1,
},
})
if err != nil {
t.Fatal(err)
}
account, found, err := repositories.Account.Load(ctx, "account-1")
if err != nil || !found {
t.Fatalf("load account: found=%v err=%v", found, err)
}
if account.HonorExp != 17699999999 || account.State != "active" || account.CreatedAt != createdAt {
t.Fatalf("account after character creation = %+v", account)
}
if account.Metadata["owner"] != "existing" || account.Metadata["source"] != "dnfbridge" {
t.Fatalf("account metadata after character creation = %+v", account.Metadata)
}
}
func TestCreatorRejectsMismatchedChildBeforeWrite(t *testing.T) {
repositories := dnfrepomemory.NewMemoryGroup()
inventory := dnfrepo.InventoryRecord{CharacterID: "other"}
err := NewCreator(repositories).Create(context.Background(), Creation{
Character: dnfrepo.CharacterRecord{CharacterID: "31", AccountID: "account-1", Name: "Test"},
Inventory: &inventory,
})
if !errors.Is(err, ErrChildCharacterMismatch) {
t.Fatalf("Create() error=%v, want ErrChildCharacterMismatch", err)
}
if _, found, loadErr := repositories.Character.Load(context.Background(), "31"); loadErr != nil || found {
t.Fatalf("mismatched creation wrote character: found=%v err=%v", found, loadErr)
}
}
@@ -0,0 +1,226 @@
package characterdata
import (
"context"
"errors"
"fmt"
"strings"
dnfpvp "longheng.io/server/internal/modules/dnf/pvp"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
var ErrChildCharacterMismatch = errors.New("character child record belongs to another character")
// Creation contains the complete durable state produced by new-character
// defaults and PVF initialization before any write is attempted.
type Creation struct {
Account *dnfrepo.AccountRecord
Character dnfrepo.CharacterRecord
Inventory *dnfrepo.InventoryRecord
Equipment *dnfrepo.EquipmentRecord
Skill *dnfrepo.SkillRecord
Quest *dnfrepo.QuestRecord
Pet *dnfrepo.PetRecord
Settings []dnfrepo.SettingsRecord
PVPPrivate *dnfpvp.PrivateCreation
}
// Creator persists one Creation through the repository transaction boundary.
type Creator struct {
repositories dnfrepo.Group
}
func NewCreator(repositories dnfrepo.Group) *Creator {
return &Creator{repositories: repositories}
}
func (c *Creator) Create(ctx context.Context, creation Creation) error {
if c == nil {
return dnfrepo.ErrCharacterCreationTransactionUnavailable
}
creation, err := normalizeCreation(creation)
if err != nil {
return err
}
if c.repositories.CharacterCreate == nil {
return dnfrepo.ErrCharacterCreationTransactionUnavailable
}
ctx = ctxOrBackground(ctx)
characterID := creation.Character.CharacterID
err = c.repositories.CharacterCreate.WithinCharacterCreation(ctx, characterID, c.repositories, func(tx dnfrepo.Group) error {
if creation.Account != nil {
if tx.Account == nil {
return repositoryError("account")
}
account := dnfrepo.CloneAccount(*creation.Account)
existing, found, err := tx.Account.Load(ctx, account.AccountID)
if err != nil {
return fmt.Errorf("load account before character creation: %w", err)
}
if found {
// Character creation owns bootstrap fields, not account-wide
// progression or unrelated metadata. Use the locked current row as
// the base and overlay only the caller's explicit bootstrap state.
if account.State != "" {
existing.State = account.State
}
if existing.Metadata == nil && len(account.Metadata) > 0 {
existing.Metadata = make(map[string]string, len(account.Metadata))
}
for key, value := range account.Metadata {
existing.Metadata[key] = value
}
if existing.CreatedAt.IsZero() {
existing.CreatedAt = account.CreatedAt
}
if !account.UpdatedAt.IsZero() {
existing.UpdatedAt = account.UpdatedAt
}
account = existing
}
if err := tx.Account.Save(ctx, account); err != nil {
return fmt.Errorf("save account: %w", err)
}
}
if tx.Character == nil {
return repositoryError("character")
}
if creation.PVPPrivate != nil {
rating, err := creation.PVPPrivate.Check(ctx, tx.Character, creation.Character.AccountID, creation.Character.CreatedAt)
if err != nil {
return err
}
creation.Character.Stats[dnfpvp.RankPointKey] = rating
}
if err := dnfrepo.CreateCharacter(ctx, tx.Character, creation.Character); err != nil {
return fmt.Errorf("create character: %w", err)
}
if creation.Inventory != nil {
if tx.Inventory == nil {
return repositoryError("inventory")
}
if err := tx.Inventory.Save(ctx, *creation.Inventory); err != nil {
return fmt.Errorf("save inventory: %w", err)
}
}
if creation.Equipment != nil {
if tx.Equipment == nil {
return repositoryError("equipment")
}
if err := tx.Equipment.Save(ctx, *creation.Equipment); err != nil {
return fmt.Errorf("save equipment: %w", err)
}
}
if creation.Skill != nil {
if tx.Skill == nil {
return repositoryError("skill")
}
if err := tx.Skill.Save(ctx, *creation.Skill); err != nil {
return fmt.Errorf("save skill: %w", err)
}
}
if creation.Quest != nil {
if tx.Quest == nil {
return repositoryError("quest")
}
if err := tx.Quest.Save(ctx, *creation.Quest); err != nil {
return fmt.Errorf("save quest: %w", err)
}
}
if creation.Pet != nil {
if tx.Pet == nil {
return repositoryError("pet")
}
if err := tx.Pet.Save(ctx, *creation.Pet); err != nil {
return fmt.Errorf("save pet: %w", err)
}
}
if len(creation.Settings) > 0 {
if tx.Settings == nil {
return repositoryError("settings")
}
for _, record := range creation.Settings {
if err := tx.Settings.Save(ctx, record); err != nil {
return fmt.Errorf("save setting %q: %w", record.Scope, err)
}
}
}
return nil
})
if err != nil {
return fmt.Errorf("persist character aggregate: %w", err)
}
return nil
}
func normalizeCreation(creation Creation) (Creation, error) {
characterID := strings.TrimSpace(creation.Character.CharacterID)
if characterID == "" {
return Creation{}, ErrCharacterIDRequired
}
creation.Character = dnfrepo.CloneCharacter(creation.Character)
creation.Character.CharacterID = characterID
if err := ValidateCharacterName(creation.Character.Name); err != nil {
return Creation{}, err
}
if creation.Account != nil {
copy := dnfrepo.CloneAccount(*creation.Account)
if strings.TrimSpace(copy.AccountID) == "" {
copy.AccountID = strings.TrimSpace(creation.Character.AccountID)
}
creation.Account = &copy
}
if creation.Inventory != nil {
copy := dnfrepo.CloneInventory(*creation.Inventory)
if err := normalizeChildID(characterID, &copy.CharacterID); err != nil {
return Creation{}, err
}
creation.Inventory = &copy
}
if creation.Equipment != nil {
copy := dnfrepo.CloneEquipment(*creation.Equipment)
if err := normalizeChildID(characterID, &copy.CharacterID); err != nil {
return Creation{}, err
}
creation.Equipment = &copy
}
if creation.Skill != nil {
copy := dnfrepo.CloneSkill(*creation.Skill)
if err := normalizeChildID(characterID, &copy.CharacterID); err != nil {
return Creation{}, err
}
creation.Skill = &copy
}
if creation.Quest != nil {
copy := dnfrepo.CloneQuest(*creation.Quest)
if err := normalizeChildID(characterID, &copy.CharacterID); err != nil {
return Creation{}, err
}
creation.Quest = &copy
}
if creation.Pet != nil {
copy := dnfrepo.ClonePet(*creation.Pet)
if err := normalizeChildID(characterID, &copy.CharacterID); err != nil {
return Creation{}, err
}
creation.Pet = &copy
}
creation.Settings = append([]dnfrepo.SettingsRecord(nil), creation.Settings...)
for index := range creation.Settings {
creation.Settings[index] = dnfrepo.CloneSettings(creation.Settings[index])
if strings.TrimSpace(creation.Settings[index].Scope) == "" {
return Creation{}, fmt.Errorf("setting scope is required at index %d", index)
}
}
return creation, nil
}
func normalizeChildID(characterID string, value *string) error {
current := strings.TrimSpace(*value)
if current != "" && current != characterID {
return fmt.Errorf("%w: expected=%s actual=%s", ErrChildCharacterMismatch, characterID, current)
}
*value = characterID
return nil
}
@@ -0,0 +1,181 @@
package characterdata
import (
"context"
"fmt"
"strings"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// Initialization is the durable, protocol-independent state used to repair a
// legacy character that predates the normal new-character creation aggregate.
// Every supplied child is inserted only when its complete record is absent.
// A present-but-empty record is intentional player state and is never changed.
type Initialization struct {
CharacterID string
Inventory *dnfrepo.InventoryRecord
Equipment *dnfrepo.EquipmentRecord
Skill *dnfrepo.SkillRecord
Settings []dnfrepo.SettingsRecord
}
// InitializationResult reports the records inserted by one idempotent repair.
// It deliberately has no "updated" state: existing data is never overwritten.
type InitializationResult struct {
Inventory bool
Equipment bool
Skill bool
Settings []string
}
func (r InitializationResult) Changed() bool {
return r.Inventory || r.Equipment || r.Skill || len(r.Settings) > 0
}
// Initializer applies an Initialization through the same aggregate transaction
// used for creation. This mirrors the 86JP inventory bootstrap model while
// preserving established character state on every subsequent login.
type Initializer struct {
repositories dnfrepo.Group
}
func NewInitializer(repositories dnfrepo.Group) *Initializer {
return &Initializer{repositories: repositories}
}
func (i *Initializer) Ensure(ctx context.Context, initialization Initialization) (InitializationResult, error) {
if i == nil {
return InitializationResult{}, dnfrepo.ErrCharacterCreationTransactionUnavailable
}
initialization, err := normalizeInitialization(initialization)
if err != nil {
return InitializationResult{}, err
}
if !initializationHasRecords(initialization) {
return InitializationResult{}, nil
}
if i.repositories.CharacterCreate == nil {
return InitializationResult{}, dnfrepo.ErrCharacterCreationTransactionUnavailable
}
ctx = ctxOrBackground(ctx)
var result InitializationResult
err = i.repositories.CharacterCreate.WithinCharacterCreation(ctx, initialization.CharacterID, i.repositories, func(tx dnfrepo.Group) error {
if initialization.Inventory != nil {
if tx.Inventory == nil {
return repositoryError("inventory")
}
_, found, loadErr := tx.Inventory.Load(ctx, initialization.CharacterID)
if loadErr != nil {
return fmt.Errorf("load inventory before initialization: %w", loadErr)
}
if !found {
if saveErr := tx.Inventory.Save(ctx, *initialization.Inventory); saveErr != nil {
return fmt.Errorf("save initialization inventory: %w", saveErr)
}
result.Inventory = true
}
}
if initialization.Equipment != nil {
if tx.Equipment == nil {
return repositoryError("equipment")
}
_, found, loadErr := tx.Equipment.Load(ctx, initialization.CharacterID)
if loadErr != nil {
return fmt.Errorf("load equipment before initialization: %w", loadErr)
}
if !found {
if saveErr := tx.Equipment.Save(ctx, *initialization.Equipment); saveErr != nil {
return fmt.Errorf("save initialization equipment: %w", saveErr)
}
result.Equipment = true
}
}
if initialization.Skill != nil {
if tx.Skill == nil {
return repositoryError("skill")
}
_, found, loadErr := tx.Skill.Load(ctx, initialization.CharacterID)
if loadErr != nil {
return fmt.Errorf("load skill before initialization: %w", loadErr)
}
if !found {
if saveErr := tx.Skill.Save(ctx, *initialization.Skill); saveErr != nil {
return fmt.Errorf("save initialization skill: %w", saveErr)
}
result.Skill = true
}
}
if len(initialization.Settings) > 0 {
if tx.Settings == nil {
return repositoryError("settings")
}
for _, setting := range initialization.Settings {
_, found, loadErr := tx.Settings.Load(ctx, setting.Scope)
if loadErr != nil {
return fmt.Errorf("load initialization setting %q: %w", setting.Scope, loadErr)
}
if found {
continue
}
if saveErr := tx.Settings.Save(ctx, setting); saveErr != nil {
return fmt.Errorf("save initialization setting %q: %w", setting.Scope, saveErr)
}
result.Settings = append(result.Settings, setting.Scope)
}
}
return nil
})
if err != nil {
return InitializationResult{}, fmt.Errorf("persist character initialization: %w", err)
}
return result, nil
}
func normalizeInitialization(initialization Initialization) (Initialization, error) {
characterID := strings.TrimSpace(initialization.CharacterID)
if characterID == "" {
return Initialization{}, ErrCharacterIDRequired
}
initialization.CharacterID = characterID
if initialization.Inventory != nil {
copy := dnfrepo.CloneInventory(*initialization.Inventory)
if err := normalizeChildID(characterID, &copy.CharacterID); err != nil {
return Initialization{}, err
}
initialization.Inventory = &copy
}
if initialization.Equipment != nil {
copy := dnfrepo.CloneEquipment(*initialization.Equipment)
if err := normalizeChildID(characterID, &copy.CharacterID); err != nil {
return Initialization{}, err
}
initialization.Equipment = &copy
}
if initialization.Skill != nil {
copy := dnfrepo.CloneSkill(*initialization.Skill)
if err := normalizeChildID(characterID, &copy.CharacterID); err != nil {
return Initialization{}, err
}
initialization.Skill = &copy
}
initialization.Settings = append([]dnfrepo.SettingsRecord(nil), initialization.Settings...)
for index := range initialization.Settings {
initialization.Settings[index] = dnfrepo.CloneSettings(initialization.Settings[index])
if strings.TrimSpace(initialization.Settings[index].Scope) == "" {
return Initialization{}, fmt.Errorf("setting scope is required at index %d", index)
}
}
return initialization, nil
}
func initializationHasRecords(initialization Initialization) bool {
return initialization.Inventory != nil ||
initialization.Equipment != nil ||
initialization.Skill != nil ||
len(initialization.Settings) > 0
}
@@ -0,0 +1,81 @@
package characterdata
import (
"context"
"testing"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestInitializerCreatesOnlyMissingRecords(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
now := time.Unix(100, 0).UTC()
initialization := Initialization{
CharacterID: "77",
Inventory: &dnfrepo.InventoryRecord{
CharacterID: "77",
Slots: map[string]dnfrepo.ItemStack{"0:1": {ItemID: 1, Count: 0}},
Warehouse: map[string]dnfrepo.ItemStack{},
UpdatedAt: now,
},
Equipment: &dnfrepo.EquipmentRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.EquipmentEntry{"11": {SlotIndex: 11, ItemID: 1001}},
UpdatedAt: now,
},
Skill: &dnfrepo.SkillRecord{
CharacterID: "77",
Skills: map[int64]dnfrepo.SkillState{10: {Level: 1, Enabled: true}},
Cooldowns: map[int64]time.Time{},
UpdatedAt: now,
},
Settings: []dnfrepo.SettingsRecord{{
Scope: "character:77:init",
Values: map[string]string{"source": "initial"},
UpdatedAt: now,
}},
}
result, err := NewInitializer(repositories).Ensure(ctx, initialization)
if err != nil {
t.Fatal(err)
}
if !result.Inventory || !result.Equipment || !result.Skill || len(result.Settings) != 1 || result.Settings[0] != "character:77:init" {
t.Fatalf("initialization result = %+v", result)
}
// A later PVF/default change must not reset durable player state. The
// initializer is allowed to insert absent records only, never replace one.
replacement := initialization
replacement.Inventory = &dnfrepo.InventoryRecord{CharacterID: "77", Slots: map[string]dnfrepo.ItemStack{"0:1": {ItemID: 2, Count: 99}}}
replacement.Equipment = &dnfrepo.EquipmentRecord{CharacterID: "77", Entries: map[string]dnfrepo.EquipmentEntry{"11": {SlotIndex: 11, ItemID: 2002}}}
replacement.Skill = &dnfrepo.SkillRecord{CharacterID: "77", Skills: map[int64]dnfrepo.SkillState{20: {Level: 9, Enabled: true}}}
replacement.Settings = []dnfrepo.SettingsRecord{{Scope: "character:77:init", Values: map[string]string{"source": "replacement"}}}
result, err = NewInitializer(repositories).Ensure(ctx, replacement)
if err != nil {
t.Fatal(err)
}
if result.Changed() {
t.Fatalf("present initialization records were changed: %+v", result)
}
inventory, _, _ := repositories.Inventory.Load(ctx, "77")
if got := inventory.Slots["0:1"]; got.ItemID != 1 || got.Count != 0 {
t.Fatalf("inventory overwritten: %+v", inventory)
}
equipment, _, _ := repositories.Equipment.Load(ctx, "77")
if got := equipment.Entries["11"].ItemID; got != 1001 {
t.Fatalf("equipment overwritten: %+v", equipment)
}
skill, _, _ := repositories.Skill.Load(ctx, "77")
if _, found := skill.Skills[10]; !found || len(skill.Skills) != 1 {
t.Fatalf("skill overwritten: %+v", skill)
}
setting, _, _ := repositories.Settings.Load(ctx, "character:77:init")
if setting.Values["source"] != "initial" {
t.Fatalf("setting overwritten: %+v", setting)
}
}
@@ -0,0 +1,46 @@
package characterdata
import (
"errors"
"fmt"
"unicode"
"unicode/utf8"
"golang.org/x/text/encoding/simplifiedchinese"
)
const (
CharacterNameMinBytes = 2
CharacterNameMaxBytes = 30
)
var ErrCharacterNameInvalid = errors.New("character name is invalid")
// ValidateCharacterName owns the durable-name safety boundary shared by
// create, duplicate-check, and rename. It keeps the current client's GB18030
// byte limits while rejecting invisible/control data and punctuation already
// unsafe in the current NoPack UI/protocol paths.
func ValidateCharacterName(name string) error {
if name == "" || !utf8.ValidString(name) {
return fmt.Errorf("%w: name must be non-empty valid UTF-8", ErrCharacterNameInvalid)
}
encoded, err := simplifiedchinese.GB18030.NewEncoder().Bytes([]byte(name))
if err != nil {
return fmt.Errorf("%w: encode GB18030: %v", ErrCharacterNameInvalid, err)
}
if len(encoded) < CharacterNameMinBytes || len(encoded) > CharacterNameMaxBytes {
return fmt.Errorf("%w: encoded length %d is outside %d..%d",
ErrCharacterNameInvalid, len(encoded), CharacterNameMinBytes, CharacterNameMaxBytes)
}
for _, value := range name {
if unicode.IsSpace(value) || unicode.IsControl(value) ||
unicode.Is(unicode.Cf, value) || !unicode.IsGraphic(value) {
return fmt.Errorf("%w: name contains whitespace, control, or invisible data", ErrCharacterNameInvalid)
}
switch value {
case '\'', '\\', '%', '<', '>', '"', '|':
return fmt.Errorf("%w: name contains forbidden character %q", ErrCharacterNameInvalid, value)
}
}
return nil
}
@@ -0,0 +1,32 @@
package characterdata
import (
"errors"
"strings"
"testing"
)
func TestValidateCharacterNameAcceptsCurrentClientNames(t *testing.T) {
for _, name := range []string{"Test", "南下就2s", "阿拉德勇士", strings.Repeat("a", CharacterNameMaxBytes)} {
if err := ValidateCharacterName(name); err != nil {
t.Fatalf("ValidateCharacterName(%q) error=%v", name, err)
}
}
}
func TestValidateCharacterNameRejectsUnsafeOrOutOfRangeNames(t *testing.T) {
for _, name := range []string{
"",
"a",
strings.Repeat("a", CharacterNameMaxBytes+1),
"Test Name",
"Test\u200bName",
"Test%Name",
"Test\\Name",
"Test<Name",
} {
if err := ValidateCharacterName(name); !errors.Is(err, ErrCharacterNameInvalid) {
t.Fatalf("ValidateCharacterName(%q) error=%v, want ErrCharacterNameInvalid", name, err)
}
}
}
@@ -0,0 +1,39 @@
package characterdata
import (
"context"
"errors"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
var ErrRosterOwnerUnavailable = errors.New("character roster owner unavailable")
// RosterOwner owns durable character-selector roster mutations.
type RosterOwner struct {
characters dnfrepo.CharacterRepository
}
func NewRosterOwner(repositories dnfrepo.Group) (*RosterOwner, error) {
if repositories.Character == nil {
return nil, ErrRosterOwnerUnavailable
}
return &RosterOwner{characters: repositories.Character}, nil
}
// SwapSlots exchanges two occupied selector slots for one account.
func (o *RosterOwner) SwapSlots(ctx context.Context, accountID string, slotA, slotB int) error {
if o == nil || o.characters == nil {
return ErrRosterOwnerUnavailable
}
return dnfrepo.SwapCharacterSlots(ctx, o.characters, accountID, slotA, slotB)
}
// DeleteCharacter validates the selector slot/name pair and commits the
// soft-delete plus active-slot compaction through the repository boundary.
func (o *RosterOwner) DeleteCharacter(ctx context.Context, accountID string, slot int, name string) error {
if o == nil || o.characters == nil {
return ErrRosterOwnerUnavailable
}
return dnfrepo.DeleteCharacterAndCompact(ctx, o.characters, accountID, slot, name)
}
@@ -0,0 +1,81 @@
package characterdata
import (
"context"
"errors"
"testing"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestRosterOwnerSwapsOnlyTheRequestedAccountsOccupiedSlots(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
for _, record := range []dnfrepo.CharacterRecord{
{CharacterID: "10", AccountID: "acc", Slot: 2, Name: "left"},
{CharacterID: "20", AccountID: "acc", Slot: 7, Name: "right"},
{CharacterID: "30", AccountID: "other", Slot: 2, Name: "other"},
} {
if err := repositories.Character.Save(ctx, record); err != nil {
t.Fatalf("save character %s: %v", record.CharacterID, err)
}
}
owner, err := NewRosterOwner(repositories)
if err != nil {
t.Fatalf("NewRosterOwner error = %v", err)
}
if err := owner.SwapSlots(ctx, "acc", 2, 7); err != nil {
t.Fatalf("SwapSlots error = %v", err)
}
assertCharacterSlot(t, ctx, repositories.Character, "10", 7)
assertCharacterSlot(t, ctx, repositories.Character, "20", 2)
assertCharacterSlot(t, ctx, repositories.Character, "30", 2)
}
func TestRosterOwnerPreservesAnOccupiedSlotWhenTheOtherSlotIsEmpty(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "10",
AccountID: "acc",
Slot: 2,
}); err != nil {
t.Fatalf("save character: %v", err)
}
owner, err := NewRosterOwner(repositories)
if err != nil {
t.Fatalf("NewRosterOwner error = %v", err)
}
if err := owner.SwapSlots(ctx, "acc", 2, 7); err != nil {
t.Fatalf("SwapSlots error = %v", err)
}
assertCharacterSlot(t, ctx, repositories.Character, "10", 2)
}
func TestRosterOwnerRejectsUnavailableRepositoryAndInvalidSlot(t *testing.T) {
if _, err := NewRosterOwner(dnfrepo.Group{}); !errors.Is(err, ErrRosterOwnerUnavailable) {
t.Fatalf("NewRosterOwner error = %v, want %v", err, ErrRosterOwnerUnavailable)
}
owner, err := NewRosterOwner(dnfrepomemory.NewMemoryGroup())
if err != nil {
t.Fatalf("NewRosterOwner error = %v", err)
}
if err := owner.SwapSlots(context.Background(), "acc", -1, 7); !errors.Is(err, dnfrepo.ErrCharacterSlotMissing) {
t.Fatalf("SwapSlots error = %v, want %v", err, dnfrepo.ErrCharacterSlotMissing)
}
}
func assertCharacterSlot(t *testing.T, ctx context.Context, repository dnfrepo.CharacterRepository, characterID string, want int) {
t.Helper()
record, found, err := repository.Load(ctx, characterID)
if err != nil {
t.Fatalf("load character %s: %v", characterID, err)
}
if !found {
t.Fatalf("character %s not found", characterID)
}
if record.Slot != want {
t.Fatalf("character %s slot = %d, want %d", characterID, record.Slot, want)
}
}
@@ -0,0 +1,4 @@
// Package charstat 从 DNF PVF 角色文件加载职业基础属性和成长属性。
//
// 本包只提供只读静态表和计算结果,不读取玩家数据库,也不写入运行时状态。
package charstat
@@ -0,0 +1,323 @@
package charstat
import (
"context"
"fmt"
"path"
"regexp"
"sort"
"strconv"
"strings"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
)
var (
tagLineRE = regexp.MustCompile(`(?is)\[\s*%s\s*\]\s*([-\d.]+)`)
growtypeTagRE = regexp.MustCompile(`(?i)\[\s*growtype\s+([1-6])\s*\]`)
awakeningTagRE = regexp.MustCompile(`(?i)\[\s*awakening\s+([1-2])\s*\]`)
initialValueTag = regexp.MustCompile(`(?i)\[\s*initial\s+value\s*\]`)
)
// Load 从 PVF 文本来源加载 character.lst 和每个职业 chr 文件。
func Load(ctx context.Context, source Source, options Options) (*Table, error) {
if source == nil {
return nil, ErrSourceRequired
}
if ctx == nil {
ctx = context.Background()
}
listPath := cleanPath(options.ListPath)
if listPath == "" {
listPath = DefaultList
}
listText, err := source.ReadText(listPath)
if err != nil {
return nil, err
}
doc, err := dnfpvf.Parse(listPath, listText)
if err != nil {
return nil, err
}
entries := dnfpvf.ParseList(doc)
if len(entries) == 0 {
return nil, fmt.Errorf("%w: %s", ErrListEmpty, listPath)
}
table := &Table{jobs: make(map[byte]jobTables)}
for _, entry := range entries {
if err := ctx.Err(); err != nil {
return nil, err
}
if entry.ID < 0 || entry.ID > 0xff || strings.TrimSpace(entry.Path) == "" {
continue
}
charPath := resolvePath(path.Dir(listPath), entry.Path)
text, actualPath, err := readTextAny(source, charPath, entry.Path)
if err != nil {
return nil, fmt.Errorf("read character stat job %d: %w", entry.ID, err)
}
tables, err := parseJobTables(actualPath, text)
if err != nil {
return nil, fmt.Errorf("parse character stat job %d %s: %w", entry.ID, actualPath, err)
}
table.jobs[byte(entry.ID)] = tables
}
if len(table.jobs) == 0 {
return nil, fmt.Errorf("%w: %s", ErrListEmpty, listPath)
}
return table, nil
}
// Compute 计算 job/growType/level 对应的职业基础属性。
func (t *Table) Compute(job byte, growType byte, level int) (Vector, error) {
if t == nil {
return Vector{}, ErrSourceRequired
}
tables, ok := t.jobs[job]
if !ok {
return Vector{}, fmt.Errorf("%w: %d", ErrJobMissing, job)
}
first, second := DecodeGrowType(growType)
if first < 0 || first > 5 || second < 0 || second > 2 {
return Vector{}, fmt.Errorf("%w: job=%d grow=%d", ErrGrowthMissing, job, growType)
}
if level < 1 {
level = 1
}
out := tables.base
for current := 1; current < level; current++ {
switch {
case current <= 14:
growth, ok := tables.growth(job, 0, 0)
if !ok {
return Vector{}, fmt.Errorf("%w: job=%d growtype=1", ErrGrowthMissing, job)
}
out.add(growth)
case current <= 49:
growth, ok := tables.growth(job, first, 0)
if !ok {
return Vector{}, fmt.Errorf("%w: job=%d growtype=%d", ErrGrowthMissing, job, first+1)
}
out.add(growth)
default:
growth, ok := tables.growth(job, first, second)
if !ok {
return Vector{}, fmt.Errorf("%w: job=%d growtype=%d awakening=%d", ErrGrowthMissing, job, first+1, second)
}
out.add(growth)
}
}
return out, nil
}
func (t jobTables) growth(_ byte, first int, second int) (Vector, bool) {
growIndex := first + 1
if growIndex < 1 || growIndex >= len(t.growtype) {
return Vector{}, false
}
if second == 0 {
return t.growtype[growIndex], t.hasGrow[growIndex]
}
if second < 0 || second >= len(t.awakening[growIndex]) {
return Vector{}, false
}
return t.awakening[growIndex][second], t.hasAwake[growIndex][second]
}
func parseJobTables(charPath string, text string) (jobTables, error) {
initial := initialValueTag.FindStringIndex(text)
if initial == nil {
return jobTables{}, fmt.Errorf("missing [initial value]")
}
growMatches := growtypeTagRE.FindAllStringSubmatchIndex(text, -1)
firstGrow := len(text)
for _, match := range growMatches {
if match[0] > initial[0] && match[0] < firstGrow {
firstGrow = match[0]
}
}
tables := jobTables{
path: charPath,
base: parseVector(text[initial[0]:firstGrow]),
}
growStarts := make([]struct {
no int
start int
end int
}, 0, len(growMatches))
for _, match := range growMatches {
no, err := strconv.Atoi(text[match[2]:match[3]])
if err != nil || no < 1 || no > 6 {
continue
}
growStarts = append(growStarts, struct {
no int
start int
end int
}{no: no, start: match[0]})
}
sort.Slice(growStarts, func(i, j int) bool { return growStarts[i].start < growStarts[j].start })
for idx := range growStarts {
end := len(text)
if idx+1 < len(growStarts) {
end = growStarts[idx+1].start
}
growStarts[idx].end = end
block := text[growStarts[idx].start:end]
parseGrowthBlock(block, growStarts[idx].no, &tables)
}
if !tables.hasGrow[1] {
return jobTables{}, fmt.Errorf("missing [growtype 1]")
}
return tables, nil
}
func parseGrowthBlock(block string, growNo int, tables *jobTables) {
awakeMatches := awakeningTagRE.FindAllStringSubmatchIndex(block, -1)
growEnd := len(block)
if len(awakeMatches) > 0 {
growEnd = awakeMatches[0][0]
}
tables.growtype[growNo] = parseVector(block[:growEnd])
tables.hasGrow[growNo] = true
for idx, match := range awakeMatches {
no, err := strconv.Atoi(block[match[2]:match[3]])
if err != nil || no < 1 || no > 2 {
continue
}
end := len(block)
if idx+1 < len(awakeMatches) {
end = awakeMatches[idx+1][0]
}
tables.awakening[growNo][no] = parseVector(block[match[0]:end])
tables.hasAwake[growNo][no] = true
}
}
func parseVector(section string) Vector {
physicalAttack := statRaw(section, "physical attack")
physicalDefense := statRaw(section, "physical defense")
magicalAttack := statRaw(section, "magical attack")
magicalDefense := statRaw(section, "magical defense")
strength := statRaw(section, "strength")
intelligence := statRaw(section, "intelligence")
vitality := statRaw(section, "vitality")
spirit := statRaw(section, "spirit")
// 当前 23.4.15.0 PVF 的 etc/avatarabilitystringtable.etc 把
// PHYSICAL_ATTACK/PHYSICAL_DEFENSE/MAGICAL_ATTACK/MAGICAL_DEFENSE 显示为
// 力量/体力/智力/精神;部分 .chr 只写旧标签,所以这里按客户端能力表补齐四维。
if strength == 0 {
strength = physicalAttack
}
if vitality == 0 {
vitality = physicalDefense
}
if intelligence == 0 {
intelligence = magicalAttack
}
if spirit == 0 {
spirit = magicalDefense
}
return Vector{
HPMax: statScaled10(section, "HP MAX"),
MPMax: statScaled10(section, "MP MAX"),
Strength: strength,
Intelligence: intelligence,
Vitality: vitality,
Spirit: spirit,
PhysicalAttack: physicalAttack,
PhysicalDefense: physicalDefense,
MagicalAttack: magicalAttack,
MagicalDefense: magicalDefense,
IndependentAttack: statRaw(section, "independent attack"),
FireResistance: statRaw(section, "fire resistance"),
WaterResistance: statRaw(section, "water resistance"),
DarkResistance: statRaw(section, "dark resistance"),
LightResistance: statRaw(section, "light resistance"),
InventoryLimit: statScaled10(section, "inventory limit"),
HPRegenSpeed: statScaled10(section, "HP regen speed"),
MPRegenSpeed: statScaled10(section, "MP regen speed"),
MoveSpeed: statScaled10(section, "move speed"),
AttackSpeed: statScaled10(section, "attack speed"),
CastSpeed: statScaled10(section, "cast speed"),
HitRecovery: statScaled10(section, "hit recovery"),
JumpPower: statScaled10(section, "jump power"),
Weight: statScaled10(section, "weight"),
}
}
func statRaw(section string, key string) int64 {
return statNumber(section, key, 1)
}
func statScaled10(section string, key string) int64 {
return statNumber(section, key, 10)
}
func statNumber(section string, key string, scale float32) int64 {
pattern := fmt.Sprintf(tagLineRE.String(), regexp.QuoteMeta(key))
matches := regexp.MustCompile(pattern).FindStringSubmatch(section)
if len(matches) < 2 {
return 0
}
value, err := strconv.ParseFloat(matches[1], 64)
if err != nil {
return 0
}
return int64(float32(value) * scale)
}
func readTextAny(source Source, candidates ...string) (string, string, error) {
seen := make(map[string]struct{}, len(candidates))
var lastErr error
for _, candidate := range candidates {
clean := cleanPath(candidate)
if clean == "" {
continue
}
key := strings.ToLower(clean)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
text, err := source.ReadText(clean)
if err == nil {
return text, clean, nil
}
lastErr = err
}
if lastErr != nil {
return "", "", lastErr
}
return "", "", fmt.Errorf("pvf path candidates are empty")
}
func resolvePath(base string, ref string) string {
base = cleanPath(base)
ref = cleanPath(ref)
if base == "" || ref == "" {
return ref
}
lowerRef := strings.ToLower(ref)
lowerBase := strings.ToLower(base)
if lowerRef == lowerBase || strings.HasPrefix(lowerRef, lowerBase+"/") {
return ref
}
return cleanPath(path.Join(base, ref))
}
func cleanPath(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 clean
}
@@ -0,0 +1,120 @@
package charstat
import (
"context"
"testing"
)
type memSource map[string]string
func (s memSource) ReadText(relativePath string) (string, error) {
if text, ok := s[cleanPath(relativePath)]; ok {
return text, nil
}
return "", errMissing(relativePath)
}
type errMissing string
func (e errMissing) Error() string { return "missing pvf text: " + string(e) }
func TestLoadComputesJobGrowLevelFromPVF(t *testing.T) {
source := memSource{
"character/character.lst": "11 `female_swordman.chr`\n",
"character/female_swordman.chr": `
[initial value]
[HP MAX] 100
[MP MAX] 50
[strength] 8
[intelligence] 9
[vitality] 7
[spirit] 6
[physical attack] 3
[magical attack] 4
[inventory limit] 48000
[move speed] 850
[growtype 1]
[HP MAX] 10
[MP MAX] 20
[strength] 1
[growtype 2]
[HP MAX] 100
[MP MAX] 200
[strength] 10
[awakening 1]
[HP MAX] 1000
[MP MAX] 2000
[strength] 100
`,
}
table, err := Load(context.Background(), source, Options{})
if err != nil {
t.Fatalf("Load() error = %v", err)
}
got, err := table.Compute(11, 0x11, 51)
if err != nil {
t.Fatalf("Compute() error = %v", err)
}
if got.HPMax != 1000+14*100+35*1000+10000 {
t.Fatalf("HPMax = %d", got.HPMax)
}
if got.MPMax != 500+14*200+35*2000+20000 {
t.Fatalf("MPMax = %d", got.MPMax)
}
if got.Strength != 8+14*1+35*10+100 {
t.Fatalf("Strength = %d", got.Strength)
}
if got.InventoryLimit != 480000 {
t.Fatalf("InventoryLimit = %d", got.InventoryLimit)
}
if got.MoveSpeed != 8500 {
t.Fatalf("MoveSpeed = %d", got.MoveSpeed)
}
}
func TestLoadMapsLegacyAttackDefenseTagsToPrimaryStats(t *testing.T) {
source := memSource{
"character/character.lst": "11 `female_swordman.chr`\n",
"character/female_swordman.chr": `
[initial value]
[HP MAX] 170
[MP MAX] 150
[physical attack] 7
[physical defense] 6
[magical attack] 5
[magical defense] 4
[growtype 1]
[HP MAX] 1
`,
}
table, err := Load(context.Background(), source, Options{})
if err != nil {
t.Fatalf("Load() error = %v", err)
}
got, err := table.Compute(11, 0, 1)
if err != nil {
t.Fatalf("Compute() error = %v", err)
}
if got.Strength != 7 || got.Vitality != 6 || got.Intelligence != 5 || got.Spirit != 4 {
t.Fatalf("primary stats = str:%d vit:%d int:%d spr:%d", got.Strength, got.Vitality, got.Intelligence, got.Spirit)
}
if got.PhysicalAttack != 7 || got.PhysicalDefense != 6 || got.MagicalAttack != 5 || got.MagicalDefense != 4 {
t.Fatalf("combat stats = pa:%d pd:%d ma:%d md:%d", got.PhysicalAttack, got.PhysicalDefense, got.MagicalAttack, got.MagicalDefense)
}
}
func TestComputeReturnsErrorWhenGrowthMissing(t *testing.T) {
source := memSource{
"character/character.lst": "1 `fighter.chr`\n",
"character/fighter.chr": "[initial value]\n[HP MAX] 1\n[growtype 1]\n[HP MAX] 1\n",
}
table, err := Load(context.Background(), source, Options{})
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if _, err := table.Compute(1, 0x01, 16); err == nil {
t.Fatalf("Compute() expected missing growth error")
}
}
@@ -0,0 +1,115 @@
package charstat
import "errors"
const DefaultList = "character/character.lst"
var (
ErrSourceRequired = errors.New("dnf character stat source is required")
ErrListEmpty = errors.New("dnf character stat list is empty")
ErrJobMissing = errors.New("dnf character stat job is missing")
ErrGrowthMissing = errors.New("dnf character stat growth is missing")
)
// Source 表示已经加载到内存的 PVF 文本来源。
type Source interface {
ReadText(relativePath string) (string, error)
}
type Options struct {
ListPath string
}
// Vector 是客户端 USERINFO 属性块使用的 10 倍整数属性。
type Vector struct {
HPMax int64
MPMax int64
Strength int64
Intelligence int64
Vitality int64
Spirit int64
PhysicalAttack int64
PhysicalDefense int64
MagicalAttack int64
MagicalDefense int64
IndependentAttack int64
FireResistance int64
WaterResistance int64
DarkResistance int64
LightResistance int64
InventoryLimit int64
HPRegenSpeed int64
MPRegenSpeed int64
MoveSpeed int64
AttackSpeed int64
CastSpeed int64
HitRecovery int64
JumpPower int64
Weight int64
}
func (v *Vector) add(other Vector) {
v.HPMax += other.HPMax
v.MPMax += other.MPMax
v.Strength += other.Strength
v.Intelligence += other.Intelligence
v.Vitality += other.Vitality
v.Spirit += other.Spirit
v.PhysicalAttack += other.PhysicalAttack
v.PhysicalDefense += other.PhysicalDefense
v.MagicalAttack += other.MagicalAttack
v.MagicalDefense += other.MagicalDefense
v.IndependentAttack += other.IndependentAttack
v.FireResistance += other.FireResistance
v.WaterResistance += other.WaterResistance
v.DarkResistance += other.DarkResistance
v.LightResistance += other.LightResistance
v.InventoryLimit += other.InventoryLimit
v.HPRegenSpeed += other.HPRegenSpeed
v.MPRegenSpeed += other.MPRegenSpeed
v.MoveSpeed += other.MoveSpeed
v.AttackSpeed += other.AttackSpeed
v.CastSpeed += other.CastSpeed
v.HitRecovery += other.HitRecovery
v.JumpPower += other.JumpPower
v.Weight += other.Weight
}
// Add merges another stat vector into v. Callers outside this package use it
// to aggregate PVF character growth with item-derived PVF stats.
func (v *Vector) Add(other Vector) {
if v == nil {
return
}
v.add(other)
}
type jobTables struct {
path string
base Vector
growtype [7]Vector
hasGrow [7]bool
awakening [7][3]Vector
hasAwake [7][3]bool
}
// Table 是按 job 编号索引的角色成长表。
type Table struct {
jobs map[byte]jobTables
}
type Snapshot struct {
Jobs int `json:"jobs"`
}
func (t *Table) Snapshot() Snapshot {
if t == nil {
return Snapshot{}
}
return Snapshot{Jobs: len(t.jobs)}
}
// DecodeGrowType 拆出客户端角色记录里的 grow_type 低 4 位转职和高 4 位觉醒。
func DecodeGrowType(growType byte) (first int, second int) {
return int(growType & 0x0f), int((growType >> 4) & 0x0f)
}
Loaded 100 of 4388 files, more files were not shown because too many files have changed in this diff. Show more