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,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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user