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