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

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

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

No files matched your search

@@ -0,0 +1,176 @@
// Package characterdata owns protocol-independent character state assembly.
// Packet builders may consume an Aggregate, but wire layouts do not belong here.
package characterdata
import (
"context"
"errors"
"fmt"
"strings"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
var (
ErrCharacterIDRequired = errors.New("character id is required")
ErrCharacterNotFound = errors.New("character not found")
ErrRepositoryUnavailable = errors.New("character data repository is unavailable")
)
// Parts selects the durable child records to load with a character.
type Parts uint32
const (
PartInventory Parts = 1 << iota
PartEquipment
PartSkill
PartQuest
PartPet
AllParts = PartInventory | PartEquipment | PartSkill | PartQuest | PartPet
)
// Presence distinguishes a missing child record from a persisted empty record.
type Presence struct {
Inventory bool
Equipment bool
Skill bool
Quest bool
Pet bool
}
// Aggregate is a detached snapshot of one character and its requested durable
// child records. Generic settings are intentionally excluded until their
// character scopes become typed repositories.
type Aggregate struct {
Character dnfrepo.CharacterRecord
Inventory dnfrepo.InventoryRecord
Equipment dnfrepo.EquipmentRecord
Skill dnfrepo.SkillRecord
Quest dnfrepo.QuestRecord
Pet dnfrepo.PetRecord
Presence Presence
}
// Loader assembles character state through repository interfaces without
// depending on dnfbridge sessions or packet formats.
type Loader struct {
repositories dnfrepo.Group
}
func NewLoader(repositories dnfrepo.Group) *Loader {
return &Loader{repositories: repositories}
}
// Load reads the character base record and requested child records.
func (l *Loader) Load(ctx context.Context, characterID string, parts Parts) (Aggregate, bool, error) {
characterID = strings.TrimSpace(characterID)
if characterID == "" {
return Aggregate{}, false, ErrCharacterIDRequired
}
if l == nil || l.repositories.Character == nil {
return Aggregate{}, false, repositoryError("character")
}
character, found, err := l.repositories.Character.Load(ctxOrBackground(ctx), characterID)
if err != nil || !found {
return Aggregate{}, found, err
}
aggregate, err := l.Hydrate(ctx, character, parts)
if err != nil {
return Aggregate{}, false, err
}
return aggregate, true, nil
}
// Hydrate attaches requested child records to an already loaded character.
func (l *Loader) Hydrate(ctx context.Context, character dnfrepo.CharacterRecord, parts Parts) (Aggregate, error) {
character = dnfrepo.CloneCharacter(character)
characterID := strings.TrimSpace(character.CharacterID)
if characterID == "" {
return Aggregate{}, ErrCharacterIDRequired
}
if l == nil {
return Aggregate{}, repositoryError("group")
}
ctx = ctxOrBackground(ctx)
aggregate := Aggregate{Character: character}
var err error
if parts&PartInventory != 0 {
if l.repositories.Inventory == nil {
return Aggregate{}, repositoryError("inventory")
}
aggregate.Inventory, aggregate.Presence.Inventory, err = l.repositories.Inventory.Load(ctx, characterID)
if err != nil {
return Aggregate{}, fmt.Errorf("load character inventory: %w", err)
}
aggregate.Inventory = dnfrepo.CloneInventory(aggregate.Inventory)
if !aggregate.Presence.Inventory {
aggregate.Inventory.CharacterID = characterID
}
}
if parts&PartEquipment != 0 {
if l.repositories.Equipment == nil {
return Aggregate{}, repositoryError("equipment")
}
aggregate.Equipment, aggregate.Presence.Equipment, err = l.repositories.Equipment.Load(ctx, characterID)
if err != nil {
return Aggregate{}, fmt.Errorf("load character equipment: %w", err)
}
aggregate.Equipment = dnfrepo.CloneEquipment(aggregate.Equipment)
if !aggregate.Presence.Equipment {
aggregate.Equipment.CharacterID = characterID
}
}
if parts&PartSkill != 0 {
if l.repositories.Skill == nil {
return Aggregate{}, repositoryError("skill")
}
aggregate.Skill, aggregate.Presence.Skill, err = l.repositories.Skill.Load(ctx, characterID)
if err != nil {
return Aggregate{}, fmt.Errorf("load character skill: %w", err)
}
aggregate.Skill = dnfrepo.CloneSkill(aggregate.Skill)
if !aggregate.Presence.Skill {
aggregate.Skill.CharacterID = characterID
}
}
if parts&PartQuest != 0 {
if l.repositories.Quest == nil {
return Aggregate{}, repositoryError("quest")
}
aggregate.Quest, aggregate.Presence.Quest, err = l.repositories.Quest.Load(ctx, characterID)
if err != nil {
return Aggregate{}, fmt.Errorf("load character quest: %w", err)
}
aggregate.Quest = dnfrepo.CloneQuest(aggregate.Quest)
if !aggregate.Presence.Quest {
aggregate.Quest.CharacterID = characterID
}
}
if parts&PartPet != 0 {
if l.repositories.Pet == nil {
return Aggregate{}, repositoryError("pet")
}
aggregate.Pet, aggregate.Presence.Pet, err = l.repositories.Pet.Load(ctx, characterID)
if err != nil {
return Aggregate{}, fmt.Errorf("load character pet: %w", err)
}
aggregate.Pet = dnfrepo.ClonePet(aggregate.Pet)
if !aggregate.Presence.Pet {
aggregate.Pet.CharacterID = characterID
}
}
return aggregate, nil
}
func repositoryError(name string) error {
return fmt.Errorf("%w: %s", ErrRepositoryUnavailable, name)
}
func ctxOrBackground(ctx context.Context) context.Context {
if ctx == nil {
return context.Background()
}
return ctx
}
@@ -0,0 +1,162 @@
package characterdata
import (
"context"
"errors"
"testing"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestLoaderLoadsRequestedCharacterParts(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
character := dnfrepo.CharacterRecord{
CharacterID: "17",
AccountID: "account-1",
Name: "hero",
Level: 1,
Stats: map[string]int64{"strength": 21},
}
if err := repositories.Character.Save(ctx, character); err != nil {
t.Fatal(err)
}
if err := repositories.Equipment.Save(ctx, dnfrepo.EquipmentRecord{
CharacterID: "17",
Entries: map[string]dnfrepo.EquipmentEntry{
"11": {SlotIndex: 11, ItemID: 1001, RawEntry: []byte{1, 2, 3}},
},
}); err != nil {
t.Fatal(err)
}
aggregate, found, err := NewLoader(repositories).Load(ctx, "17", PartEquipment|PartSkill)
if err != nil || !found {
t.Fatalf("Load() found=%v err=%v", found, err)
}
if !aggregate.Presence.Equipment || aggregate.Equipment.Entries["11"].ItemID != 1001 {
t.Fatalf("equipment not loaded: %+v", aggregate)
}
if aggregate.Presence.Skill || aggregate.Skill.CharacterID != "17" {
t.Fatalf("missing skill presence not preserved: %+v", aggregate)
}
character.Stats["strength"] = 999
entry := aggregate.Equipment.Entries["11"]
entry.RawEntry[0] = 9
stored, _, err := repositories.Equipment.Load(ctx, "17")
if err != nil {
t.Fatal(err)
}
if stored.Entries["11"].RawEntry[0] != 1 {
t.Fatalf("aggregate shares mutable equipment bytes: %v", stored.Entries["11"].RawEntry)
}
}
func TestCreatorPersistsCompleteInitialization(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
now := time.Unix(100, 0).UTC()
inventory := dnfrepo.InventoryRecord{Slots: map[string]dnfrepo.ItemStack{"0:1": {ItemID: 1}}}
equipment := dnfrepo.EquipmentRecord{Entries: map[string]dnfrepo.EquipmentEntry{"11": {SlotIndex: 11, ItemID: 1001}}}
skill := dnfrepo.SkillRecord{Skills: map[int64]dnfrepo.SkillState{10: {Level: 1, Enabled: true}}}
err := NewCreator(repositories).Create(ctx, Creation{
Account: &dnfrepo.AccountRecord{AccountID: "account-1", State: "active"},
Character: dnfrepo.CharacterRecord{
CharacterID: "21",
AccountID: "account-1",
Slot: 0,
Name: "hero",
Level: 1,
CreatedAt: now,
UpdatedAt: now,
},
Inventory: &inventory,
Equipment: &equipment,
Skill: &skill,
Settings: []dnfrepo.SettingsRecord{{
Scope: dnfrepo.CharacterContainerStateScope("21"),
Values: map[string]string{"main_slots": "24"},
}},
})
if err != nil {
t.Fatal(err)
}
if _, found, err := repositories.Character.Load(ctx, "21"); err != nil || !found {
t.Fatalf("character found=%v err=%v", found, err)
}
for name, load := range map[string]func() (bool, error){
"inventory": func() (bool, error) { _, found, err := repositories.Inventory.Load(ctx, "21"); return found, err },
"equipment": func() (bool, error) { _, found, err := repositories.Equipment.Load(ctx, "21"); return found, err },
"skill": func() (bool, error) { _, found, err := repositories.Skill.Load(ctx, "21"); return found, err },
"settings": func() (bool, error) {
_, found, err := repositories.Settings.Load(ctx, dnfrepo.CharacterContainerStateScope("21"))
return found, err
},
} {
found, err := load()
if err != nil || !found {
t.Fatalf("%s found=%v err=%v", name, found, err)
}
}
}
func TestCreatorPreservesExistingAccountHonorExperience(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
createdAt := time.Unix(50, 0).UTC()
if err := repositories.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "account-1",
State: "existing",
HonorExp: 17699999999,
Metadata: map[string]string{"owner": "existing"},
CreatedAt: createdAt,
}); err != nil {
t.Fatal(err)
}
err := NewCreator(repositories).Create(ctx, Creation{
Account: &dnfrepo.AccountRecord{
AccountID: "account-1",
State: "active",
HonorExp: 0,
Metadata: map[string]string{"source": "dnfbridge"},
},
Character: dnfrepo.CharacterRecord{
CharacterID: "22",
AccountID: "account-1",
Name: "Test",
Slot: 1,
Level: 1,
},
})
if err != nil {
t.Fatal(err)
}
account, found, err := repositories.Account.Load(ctx, "account-1")
if err != nil || !found {
t.Fatalf("load account: found=%v err=%v", found, err)
}
if account.HonorExp != 17699999999 || account.State != "active" || account.CreatedAt != createdAt {
t.Fatalf("account after character creation = %+v", account)
}
if account.Metadata["owner"] != "existing" || account.Metadata["source"] != "dnfbridge" {
t.Fatalf("account metadata after character creation = %+v", account.Metadata)
}
}
func TestCreatorRejectsMismatchedChildBeforeWrite(t *testing.T) {
repositories := dnfrepomemory.NewMemoryGroup()
inventory := dnfrepo.InventoryRecord{CharacterID: "other"}
err := NewCreator(repositories).Create(context.Background(), Creation{
Character: dnfrepo.CharacterRecord{CharacterID: "31", AccountID: "account-1", Name: "Test"},
Inventory: &inventory,
})
if !errors.Is(err, ErrChildCharacterMismatch) {
t.Fatalf("Create() error=%v, want ErrChildCharacterMismatch", err)
}
if _, found, loadErr := repositories.Character.Load(context.Background(), "31"); loadErr != nil || found {
t.Fatalf("mismatched creation wrote character: found=%v err=%v", found, loadErr)
}
}
@@ -0,0 +1,226 @@
package characterdata
import (
"context"
"errors"
"fmt"
"strings"
dnfpvp "longheng.io/server/internal/modules/dnf/pvp"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
var ErrChildCharacterMismatch = errors.New("character child record belongs to another character")
// Creation contains the complete durable state produced by new-character
// defaults and PVF initialization before any write is attempted.
type Creation struct {
Account *dnfrepo.AccountRecord
Character dnfrepo.CharacterRecord
Inventory *dnfrepo.InventoryRecord
Equipment *dnfrepo.EquipmentRecord
Skill *dnfrepo.SkillRecord
Quest *dnfrepo.QuestRecord
Pet *dnfrepo.PetRecord
Settings []dnfrepo.SettingsRecord
PVPPrivate *dnfpvp.PrivateCreation
}
// Creator persists one Creation through the repository transaction boundary.
type Creator struct {
repositories dnfrepo.Group
}
func NewCreator(repositories dnfrepo.Group) *Creator {
return &Creator{repositories: repositories}
}
func (c *Creator) Create(ctx context.Context, creation Creation) error {
if c == nil {
return dnfrepo.ErrCharacterCreationTransactionUnavailable
}
creation, err := normalizeCreation(creation)
if err != nil {
return err
}
if c.repositories.CharacterCreate == nil {
return dnfrepo.ErrCharacterCreationTransactionUnavailable
}
ctx = ctxOrBackground(ctx)
characterID := creation.Character.CharacterID
err = c.repositories.CharacterCreate.WithinCharacterCreation(ctx, characterID, c.repositories, func(tx dnfrepo.Group) error {
if creation.Account != nil {
if tx.Account == nil {
return repositoryError("account")
}
account := dnfrepo.CloneAccount(*creation.Account)
existing, found, err := tx.Account.Load(ctx, account.AccountID)
if err != nil {
return fmt.Errorf("load account before character creation: %w", err)
}
if found {
// Character creation owns bootstrap fields, not account-wide
// progression or unrelated metadata. Use the locked current row as
// the base and overlay only the caller's explicit bootstrap state.
if account.State != "" {
existing.State = account.State
}
if existing.Metadata == nil && len(account.Metadata) > 0 {
existing.Metadata = make(map[string]string, len(account.Metadata))
}
for key, value := range account.Metadata {
existing.Metadata[key] = value
}
if existing.CreatedAt.IsZero() {
existing.CreatedAt = account.CreatedAt
}
if !account.UpdatedAt.IsZero() {
existing.UpdatedAt = account.UpdatedAt
}
account = existing
}
if err := tx.Account.Save(ctx, account); err != nil {
return fmt.Errorf("save account: %w", err)
}
}
if tx.Character == nil {
return repositoryError("character")
}
if creation.PVPPrivate != nil {
rating, err := creation.PVPPrivate.Check(ctx, tx.Character, creation.Character.AccountID, creation.Character.CreatedAt)
if err != nil {
return err
}
creation.Character.Stats[dnfpvp.RankPointKey] = rating
}
if err := dnfrepo.CreateCharacter(ctx, tx.Character, creation.Character); err != nil {
return fmt.Errorf("create character: %w", err)
}
if creation.Inventory != nil {
if tx.Inventory == nil {
return repositoryError("inventory")
}
if err := tx.Inventory.Save(ctx, *creation.Inventory); err != nil {
return fmt.Errorf("save inventory: %w", err)
}
}
if creation.Equipment != nil {
if tx.Equipment == nil {
return repositoryError("equipment")
}
if err := tx.Equipment.Save(ctx, *creation.Equipment); err != nil {
return fmt.Errorf("save equipment: %w", err)
}
}
if creation.Skill != nil {
if tx.Skill == nil {
return repositoryError("skill")
}
if err := tx.Skill.Save(ctx, *creation.Skill); err != nil {
return fmt.Errorf("save skill: %w", err)
}
}
if creation.Quest != nil {
if tx.Quest == nil {
return repositoryError("quest")
}
if err := tx.Quest.Save(ctx, *creation.Quest); err != nil {
return fmt.Errorf("save quest: %w", err)
}
}
if creation.Pet != nil {
if tx.Pet == nil {
return repositoryError("pet")
}
if err := tx.Pet.Save(ctx, *creation.Pet); err != nil {
return fmt.Errorf("save pet: %w", err)
}
}
if len(creation.Settings) > 0 {
if tx.Settings == nil {
return repositoryError("settings")
}
for _, record := range creation.Settings {
if err := tx.Settings.Save(ctx, record); err != nil {
return fmt.Errorf("save setting %q: %w", record.Scope, err)
}
}
}
return nil
})
if err != nil {
return fmt.Errorf("persist character aggregate: %w", err)
}
return nil
}
func normalizeCreation(creation Creation) (Creation, error) {
characterID := strings.TrimSpace(creation.Character.CharacterID)
if characterID == "" {
return Creation{}, ErrCharacterIDRequired
}
creation.Character = dnfrepo.CloneCharacter(creation.Character)
creation.Character.CharacterID = characterID
if err := ValidateCharacterName(creation.Character.Name); err != nil {
return Creation{}, err
}
if creation.Account != nil {
copy := dnfrepo.CloneAccount(*creation.Account)
if strings.TrimSpace(copy.AccountID) == "" {
copy.AccountID = strings.TrimSpace(creation.Character.AccountID)
}
creation.Account = &copy
}
if creation.Inventory != nil {
copy := dnfrepo.CloneInventory(*creation.Inventory)
if err := normalizeChildID(characterID, &copy.CharacterID); err != nil {
return Creation{}, err
}
creation.Inventory = &copy
}
if creation.Equipment != nil {
copy := dnfrepo.CloneEquipment(*creation.Equipment)
if err := normalizeChildID(characterID, &copy.CharacterID); err != nil {
return Creation{}, err
}
creation.Equipment = &copy
}
if creation.Skill != nil {
copy := dnfrepo.CloneSkill(*creation.Skill)
if err := normalizeChildID(characterID, &copy.CharacterID); err != nil {
return Creation{}, err
}
creation.Skill = &copy
}
if creation.Quest != nil {
copy := dnfrepo.CloneQuest(*creation.Quest)
if err := normalizeChildID(characterID, &copy.CharacterID); err != nil {
return Creation{}, err
}
creation.Quest = &copy
}
if creation.Pet != nil {
copy := dnfrepo.ClonePet(*creation.Pet)
if err := normalizeChildID(characterID, &copy.CharacterID); err != nil {
return Creation{}, err
}
creation.Pet = &copy
}
creation.Settings = append([]dnfrepo.SettingsRecord(nil), creation.Settings...)
for index := range creation.Settings {
creation.Settings[index] = dnfrepo.CloneSettings(creation.Settings[index])
if strings.TrimSpace(creation.Settings[index].Scope) == "" {
return Creation{}, fmt.Errorf("setting scope is required at index %d", index)
}
}
return creation, nil
}
func normalizeChildID(characterID string, value *string) error {
current := strings.TrimSpace(*value)
if current != "" && current != characterID {
return fmt.Errorf("%w: expected=%s actual=%s", ErrChildCharacterMismatch, characterID, current)
}
*value = characterID
return nil
}
@@ -0,0 +1,181 @@
package characterdata
import (
"context"
"fmt"
"strings"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// Initialization is the durable, protocol-independent state used to repair a
// legacy character that predates the normal new-character creation aggregate.
// Every supplied child is inserted only when its complete record is absent.
// A present-but-empty record is intentional player state and is never changed.
type Initialization struct {
CharacterID string
Inventory *dnfrepo.InventoryRecord
Equipment *dnfrepo.EquipmentRecord
Skill *dnfrepo.SkillRecord
Settings []dnfrepo.SettingsRecord
}
// InitializationResult reports the records inserted by one idempotent repair.
// It deliberately has no "updated" state: existing data is never overwritten.
type InitializationResult struct {
Inventory bool
Equipment bool
Skill bool
Settings []string
}
func (r InitializationResult) Changed() bool {
return r.Inventory || r.Equipment || r.Skill || len(r.Settings) > 0
}
// Initializer applies an Initialization through the same aggregate transaction
// used for creation. This mirrors the 86JP inventory bootstrap model while
// preserving established character state on every subsequent login.
type Initializer struct {
repositories dnfrepo.Group
}
func NewInitializer(repositories dnfrepo.Group) *Initializer {
return &Initializer{repositories: repositories}
}
func (i *Initializer) Ensure(ctx context.Context, initialization Initialization) (InitializationResult, error) {
if i == nil {
return InitializationResult{}, dnfrepo.ErrCharacterCreationTransactionUnavailable
}
initialization, err := normalizeInitialization(initialization)
if err != nil {
return InitializationResult{}, err
}
if !initializationHasRecords(initialization) {
return InitializationResult{}, nil
}
if i.repositories.CharacterCreate == nil {
return InitializationResult{}, dnfrepo.ErrCharacterCreationTransactionUnavailable
}
ctx = ctxOrBackground(ctx)
var result InitializationResult
err = i.repositories.CharacterCreate.WithinCharacterCreation(ctx, initialization.CharacterID, i.repositories, func(tx dnfrepo.Group) error {
if initialization.Inventory != nil {
if tx.Inventory == nil {
return repositoryError("inventory")
}
_, found, loadErr := tx.Inventory.Load(ctx, initialization.CharacterID)
if loadErr != nil {
return fmt.Errorf("load inventory before initialization: %w", loadErr)
}
if !found {
if saveErr := tx.Inventory.Save(ctx, *initialization.Inventory); saveErr != nil {
return fmt.Errorf("save initialization inventory: %w", saveErr)
}
result.Inventory = true
}
}
if initialization.Equipment != nil {
if tx.Equipment == nil {
return repositoryError("equipment")
}
_, found, loadErr := tx.Equipment.Load(ctx, initialization.CharacterID)
if loadErr != nil {
return fmt.Errorf("load equipment before initialization: %w", loadErr)
}
if !found {
if saveErr := tx.Equipment.Save(ctx, *initialization.Equipment); saveErr != nil {
return fmt.Errorf("save initialization equipment: %w", saveErr)
}
result.Equipment = true
}
}
if initialization.Skill != nil {
if tx.Skill == nil {
return repositoryError("skill")
}
_, found, loadErr := tx.Skill.Load(ctx, initialization.CharacterID)
if loadErr != nil {
return fmt.Errorf("load skill before initialization: %w", loadErr)
}
if !found {
if saveErr := tx.Skill.Save(ctx, *initialization.Skill); saveErr != nil {
return fmt.Errorf("save initialization skill: %w", saveErr)
}
result.Skill = true
}
}
if len(initialization.Settings) > 0 {
if tx.Settings == nil {
return repositoryError("settings")
}
for _, setting := range initialization.Settings {
_, found, loadErr := tx.Settings.Load(ctx, setting.Scope)
if loadErr != nil {
return fmt.Errorf("load initialization setting %q: %w", setting.Scope, loadErr)
}
if found {
continue
}
if saveErr := tx.Settings.Save(ctx, setting); saveErr != nil {
return fmt.Errorf("save initialization setting %q: %w", setting.Scope, saveErr)
}
result.Settings = append(result.Settings, setting.Scope)
}
}
return nil
})
if err != nil {
return InitializationResult{}, fmt.Errorf("persist character initialization: %w", err)
}
return result, nil
}
func normalizeInitialization(initialization Initialization) (Initialization, error) {
characterID := strings.TrimSpace(initialization.CharacterID)
if characterID == "" {
return Initialization{}, ErrCharacterIDRequired
}
initialization.CharacterID = characterID
if initialization.Inventory != nil {
copy := dnfrepo.CloneInventory(*initialization.Inventory)
if err := normalizeChildID(characterID, &copy.CharacterID); err != nil {
return Initialization{}, err
}
initialization.Inventory = &copy
}
if initialization.Equipment != nil {
copy := dnfrepo.CloneEquipment(*initialization.Equipment)
if err := normalizeChildID(characterID, &copy.CharacterID); err != nil {
return Initialization{}, err
}
initialization.Equipment = &copy
}
if initialization.Skill != nil {
copy := dnfrepo.CloneSkill(*initialization.Skill)
if err := normalizeChildID(characterID, &copy.CharacterID); err != nil {
return Initialization{}, err
}
initialization.Skill = &copy
}
initialization.Settings = append([]dnfrepo.SettingsRecord(nil), initialization.Settings...)
for index := range initialization.Settings {
initialization.Settings[index] = dnfrepo.CloneSettings(initialization.Settings[index])
if strings.TrimSpace(initialization.Settings[index].Scope) == "" {
return Initialization{}, fmt.Errorf("setting scope is required at index %d", index)
}
}
return initialization, nil
}
func initializationHasRecords(initialization Initialization) bool {
return initialization.Inventory != nil ||
initialization.Equipment != nil ||
initialization.Skill != nil ||
len(initialization.Settings) > 0
}
@@ -0,0 +1,81 @@
package characterdata
import (
"context"
"testing"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestInitializerCreatesOnlyMissingRecords(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
now := time.Unix(100, 0).UTC()
initialization := Initialization{
CharacterID: "77",
Inventory: &dnfrepo.InventoryRecord{
CharacterID: "77",
Slots: map[string]dnfrepo.ItemStack{"0:1": {ItemID: 1, Count: 0}},
Warehouse: map[string]dnfrepo.ItemStack{},
UpdatedAt: now,
},
Equipment: &dnfrepo.EquipmentRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.EquipmentEntry{"11": {SlotIndex: 11, ItemID: 1001}},
UpdatedAt: now,
},
Skill: &dnfrepo.SkillRecord{
CharacterID: "77",
Skills: map[int64]dnfrepo.SkillState{10: {Level: 1, Enabled: true}},
Cooldowns: map[int64]time.Time{},
UpdatedAt: now,
},
Settings: []dnfrepo.SettingsRecord{{
Scope: "character:77:init",
Values: map[string]string{"source": "initial"},
UpdatedAt: now,
}},
}
result, err := NewInitializer(repositories).Ensure(ctx, initialization)
if err != nil {
t.Fatal(err)
}
if !result.Inventory || !result.Equipment || !result.Skill || len(result.Settings) != 1 || result.Settings[0] != "character:77:init" {
t.Fatalf("initialization result = %+v", result)
}
// A later PVF/default change must not reset durable player state. The
// initializer is allowed to insert absent records only, never replace one.
replacement := initialization
replacement.Inventory = &dnfrepo.InventoryRecord{CharacterID: "77", Slots: map[string]dnfrepo.ItemStack{"0:1": {ItemID: 2, Count: 99}}}
replacement.Equipment = &dnfrepo.EquipmentRecord{CharacterID: "77", Entries: map[string]dnfrepo.EquipmentEntry{"11": {SlotIndex: 11, ItemID: 2002}}}
replacement.Skill = &dnfrepo.SkillRecord{CharacterID: "77", Skills: map[int64]dnfrepo.SkillState{20: {Level: 9, Enabled: true}}}
replacement.Settings = []dnfrepo.SettingsRecord{{Scope: "character:77:init", Values: map[string]string{"source": "replacement"}}}
result, err = NewInitializer(repositories).Ensure(ctx, replacement)
if err != nil {
t.Fatal(err)
}
if result.Changed() {
t.Fatalf("present initialization records were changed: %+v", result)
}
inventory, _, _ := repositories.Inventory.Load(ctx, "77")
if got := inventory.Slots["0:1"]; got.ItemID != 1 || got.Count != 0 {
t.Fatalf("inventory overwritten: %+v", inventory)
}
equipment, _, _ := repositories.Equipment.Load(ctx, "77")
if got := equipment.Entries["11"].ItemID; got != 1001 {
t.Fatalf("equipment overwritten: %+v", equipment)
}
skill, _, _ := repositories.Skill.Load(ctx, "77")
if _, found := skill.Skills[10]; !found || len(skill.Skills) != 1 {
t.Fatalf("skill overwritten: %+v", skill)
}
setting, _, _ := repositories.Settings.Load(ctx, "character:77:init")
if setting.Values["source"] != "initial" {
t.Fatalf("setting overwritten: %+v", setting)
}
}
@@ -0,0 +1,46 @@
package characterdata
import (
"errors"
"fmt"
"unicode"
"unicode/utf8"
"golang.org/x/text/encoding/simplifiedchinese"
)
const (
CharacterNameMinBytes = 2
CharacterNameMaxBytes = 30
)
var ErrCharacterNameInvalid = errors.New("character name is invalid")
// ValidateCharacterName owns the durable-name safety boundary shared by
// create, duplicate-check, and rename. It keeps the current client's GB18030
// byte limits while rejecting invisible/control data and punctuation already
// unsafe in the current NoPack UI/protocol paths.
func ValidateCharacterName(name string) error {
if name == "" || !utf8.ValidString(name) {
return fmt.Errorf("%w: name must be non-empty valid UTF-8", ErrCharacterNameInvalid)
}
encoded, err := simplifiedchinese.GB18030.NewEncoder().Bytes([]byte(name))
if err != nil {
return fmt.Errorf("%w: encode GB18030: %v", ErrCharacterNameInvalid, err)
}
if len(encoded) < CharacterNameMinBytes || len(encoded) > CharacterNameMaxBytes {
return fmt.Errorf("%w: encoded length %d is outside %d..%d",
ErrCharacterNameInvalid, len(encoded), CharacterNameMinBytes, CharacterNameMaxBytes)
}
for _, value := range name {
if unicode.IsSpace(value) || unicode.IsControl(value) ||
unicode.Is(unicode.Cf, value) || !unicode.IsGraphic(value) {
return fmt.Errorf("%w: name contains whitespace, control, or invisible data", ErrCharacterNameInvalid)
}
switch value {
case '\'', '\\', '%', '<', '>', '"', '|':
return fmt.Errorf("%w: name contains forbidden character %q", ErrCharacterNameInvalid, value)
}
}
return nil
}
@@ -0,0 +1,32 @@
package characterdata
import (
"errors"
"strings"
"testing"
)
func TestValidateCharacterNameAcceptsCurrentClientNames(t *testing.T) {
for _, name := range []string{"Test", "南下就2s", "阿拉德勇士", strings.Repeat("a", CharacterNameMaxBytes)} {
if err := ValidateCharacterName(name); err != nil {
t.Fatalf("ValidateCharacterName(%q) error=%v", name, err)
}
}
}
func TestValidateCharacterNameRejectsUnsafeOrOutOfRangeNames(t *testing.T) {
for _, name := range []string{
"",
"a",
strings.Repeat("a", CharacterNameMaxBytes+1),
"Test Name",
"Test\u200bName",
"Test%Name",
"Test\\Name",
"Test<Name",
} {
if err := ValidateCharacterName(name); !errors.Is(err, ErrCharacterNameInvalid) {
t.Fatalf("ValidateCharacterName(%q) error=%v, want ErrCharacterNameInvalid", name, err)
}
}
}
@@ -0,0 +1,39 @@
package characterdata
import (
"context"
"errors"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
var ErrRosterOwnerUnavailable = errors.New("character roster owner unavailable")
// RosterOwner owns durable character-selector roster mutations.
type RosterOwner struct {
characters dnfrepo.CharacterRepository
}
func NewRosterOwner(repositories dnfrepo.Group) (*RosterOwner, error) {
if repositories.Character == nil {
return nil, ErrRosterOwnerUnavailable
}
return &RosterOwner{characters: repositories.Character}, nil
}
// SwapSlots exchanges two occupied selector slots for one account.
func (o *RosterOwner) SwapSlots(ctx context.Context, accountID string, slotA, slotB int) error {
if o == nil || o.characters == nil {
return ErrRosterOwnerUnavailable
}
return dnfrepo.SwapCharacterSlots(ctx, o.characters, accountID, slotA, slotB)
}
// DeleteCharacter validates the selector slot/name pair and commits the
// soft-delete plus active-slot compaction through the repository boundary.
func (o *RosterOwner) DeleteCharacter(ctx context.Context, accountID string, slot int, name string) error {
if o == nil || o.characters == nil {
return ErrRosterOwnerUnavailable
}
return dnfrepo.DeleteCharacterAndCompact(ctx, o.characters, accountID, slot, name)
}
@@ -0,0 +1,81 @@
package characterdata
import (
"context"
"errors"
"testing"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestRosterOwnerSwapsOnlyTheRequestedAccountsOccupiedSlots(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
for _, record := range []dnfrepo.CharacterRecord{
{CharacterID: "10", AccountID: "acc", Slot: 2, Name: "left"},
{CharacterID: "20", AccountID: "acc", Slot: 7, Name: "right"},
{CharacterID: "30", AccountID: "other", Slot: 2, Name: "other"},
} {
if err := repositories.Character.Save(ctx, record); err != nil {
t.Fatalf("save character %s: %v", record.CharacterID, err)
}
}
owner, err := NewRosterOwner(repositories)
if err != nil {
t.Fatalf("NewRosterOwner error = %v", err)
}
if err := owner.SwapSlots(ctx, "acc", 2, 7); err != nil {
t.Fatalf("SwapSlots error = %v", err)
}
assertCharacterSlot(t, ctx, repositories.Character, "10", 7)
assertCharacterSlot(t, ctx, repositories.Character, "20", 2)
assertCharacterSlot(t, ctx, repositories.Character, "30", 2)
}
func TestRosterOwnerPreservesAnOccupiedSlotWhenTheOtherSlotIsEmpty(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "10",
AccountID: "acc",
Slot: 2,
}); err != nil {
t.Fatalf("save character: %v", err)
}
owner, err := NewRosterOwner(repositories)
if err != nil {
t.Fatalf("NewRosterOwner error = %v", err)
}
if err := owner.SwapSlots(ctx, "acc", 2, 7); err != nil {
t.Fatalf("SwapSlots error = %v", err)
}
assertCharacterSlot(t, ctx, repositories.Character, "10", 2)
}
func TestRosterOwnerRejectsUnavailableRepositoryAndInvalidSlot(t *testing.T) {
if _, err := NewRosterOwner(dnfrepo.Group{}); !errors.Is(err, ErrRosterOwnerUnavailable) {
t.Fatalf("NewRosterOwner error = %v, want %v", err, ErrRosterOwnerUnavailable)
}
owner, err := NewRosterOwner(dnfrepomemory.NewMemoryGroup())
if err != nil {
t.Fatalf("NewRosterOwner error = %v", err)
}
if err := owner.SwapSlots(context.Background(), "acc", -1, 7); !errors.Is(err, dnfrepo.ErrCharacterSlotMissing) {
t.Fatalf("SwapSlots error = %v, want %v", err, dnfrepo.ErrCharacterSlotMissing)
}
}
func assertCharacterSlot(t *testing.T, ctx context.Context, repository dnfrepo.CharacterRepository, characterID string, want int) {
t.Helper()
record, found, err := repository.Load(ctx, characterID)
if err != nil {
t.Fatalf("load character %s: %v", characterID, err)
}
if !found {
t.Fatalf("character %s not found", characterID)
}
if record.Slot != want {
t.Fatalf("character %s slot = %d, want %d", characterID, record.Slot, want)
}
}