diff --git a/internal/persona/emotion/affection.go b/internal/persona/emotion/affection.go new file mode 100644 index 0000000..e1186ac --- /dev/null +++ b/internal/persona/emotion/affection.go @@ -0,0 +1,104 @@ +package emotion + +import ( + "fmt" + "sync" +) + +// Affection tracks a persona's affection value (好感度) bounded to [min, max]. +// All methods are safe for concurrent use and the value is clamped on every +// mutation so it never escapes its bounds. +type Affection struct { + mu sync.RWMutex + value int + min int + max int +} + +// NewAffection builds an Affection bounded to [min, max]. It fails when the +// bounds are inverted or the initial value lies outside them. +func NewAffection(initial, min, max int) (*Affection, error) { + if min > max { + return nil, fmt.Errorf("emotion: affection min %d exceeds max %d", min, max) + } + if initial < min || initial > max { + return nil, fmt.Errorf("emotion: affection initial %d outside [%d, %d]", initial, min, max) + } + return &Affection{value: initial, min: min, max: max}, nil +} + +// Value returns the current affection. +func (a *Affection) Value() int { + a.mu.RLock() + defer a.mu.RUnlock() + return a.value +} + +// Min returns the lower bound. +func (a *Affection) Min() int { + a.mu.RLock() + defer a.mu.RUnlock() + return a.min +} + +// Max returns the upper bound. +func (a *Affection) Max() int { + a.mu.RLock() + defer a.mu.RUnlock() + return a.max +} + +// Increase adds delta to the value, clamping at the upper bound. Deltas that +// are not positive leave the value unchanged. The resulting value is returned. +func (a *Affection) Increase(delta int) int { + a.mu.Lock() + defer a.mu.Unlock() + if delta > 0 { + a.value = clampInt(a.value+delta, a.min, a.max) + } + return a.value +} + +// Decrease subtracts delta from the value, clamping at the lower bound. Deltas +// that are not positive leave the value unchanged. The resulting value is +// returned. +func (a *Affection) Decrease(delta int) int { + a.mu.Lock() + defer a.mu.Unlock() + if delta > 0 { + a.value = clampInt(a.value-delta, a.min, a.max) + } + return a.value +} + +// Adjust applies a signed delta to the value — a positive delta increases it, +// a negative delta decreases it — clamping to the bounds. It is the event- +// driven entry point (随事件变化): before and after values are returned so the +// caller can observe the full change. +func (a *Affection) Adjust(delta int) (before, after int) { + a.mu.Lock() + defer a.mu.Unlock() + before = a.value + a.value = clampInt(a.value+delta, a.min, a.max) + return before, a.value +} + +// Set forces the value to v, clamped to the bounds. The resulting value is +// returned. +func (a *Affection) Set(v int) int { + a.mu.Lock() + defer a.mu.Unlock() + a.value = clampInt(v, a.min, a.max) + return a.value +} + +// clampInt restricts v into [lo, hi]. +func clampInt(v, lo, hi int) int { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} diff --git a/internal/persona/emotion/affection_test.go b/internal/persona/emotion/affection_test.go new file mode 100644 index 0000000..e226e34 --- /dev/null +++ b/internal/persona/emotion/affection_test.go @@ -0,0 +1,84 @@ +package emotion + +import "testing" + +func TestNewAffection_RejectsBadBounds(t *testing.T) { + t.Parallel() + + if _, err := NewAffection(50, 100, 0); err == nil { + t.Fatal("NewAffection() should error when min > max") + } + if _, err := NewAffection(200, 0, 100); err == nil { + t.Fatal("NewAffection() should error when initial > max") + } + if _, err := NewAffection(-1, 0, 100); err == nil { + t.Fatal("NewAffection() should error when initial < min") + } +} + +func TestAffection_IncreaseAndDecreaseClamp(t *testing.T) { + t.Parallel() + + a, err := NewAffection(50, 0, 100) + if err != nil { + t.Fatalf("NewAffection() error: %v", err) + } + + if got := a.Increase(30); got != 80 { + t.Fatalf("Increase(30) = %d, want 80", got) + } + if got := a.Increase(100); got != 100 { + t.Fatalf("Increase(100) clamped = %d, want 100", got) + } + if got := a.Decrease(40); got != 60 { + t.Fatalf("Decrease(40) = %d, want 60", got) + } + if got := a.Decrease(1000); got != 0 { + t.Fatalf("Decrease(1000) clamped = %d, want 0", got) + } +} + +func TestAffection_IncreaseIgnoresNonPositiveDelta(t *testing.T) { + t.Parallel() + + a, err := NewAffection(50, 0, 100) + if err != nil { + t.Fatalf("NewAffection() error: %v", err) + } + if got := a.Increase(0); got != 50 { + t.Fatalf("Increase(0) = %d, want 50", got) + } + if got := a.Decrease(-5); got != 50 { + t.Fatalf("Decrease(-5) = %d, want 50", got) + } +} + +func TestAffection_AdjustSigned(t *testing.T) { + t.Parallel() + + a, err := NewAffection(40, 0, 100) + if err != nil { + t.Fatalf("NewAffection() error: %v", err) + } + + before, after := a.Adjust(30) + if before != 40 || after != 70 { + t.Fatalf("Adjust(+30) before/after = %d/%d, want 40/70", before, after) + } + before, after = a.Adjust(-100) + if before != 70 || after != 0 { + t.Fatalf("Adjust(-100) before/after = %d/%d, want 70/0", before, after) + } +} + +func TestAffection_BoundsReported(t *testing.T) { + t.Parallel() + + a, err := NewAffection(10, 0, 200) + if err != nil { + t.Fatalf("NewAffection() error: %v", err) + } + if a.Min() != 0 || a.Max() != 200 { + t.Fatalf("Min/Max = %d/%d, want 0/200", a.Min(), a.Max()) + } +} diff --git a/internal/persona/emotion/manager.go b/internal/persona/emotion/manager.go new file mode 100644 index 0000000..5a5de56 --- /dev/null +++ b/internal/persona/emotion/manager.go @@ -0,0 +1,90 @@ +package emotion + +import ( + "errors" + "fmt" + "sync" +) + +// ErrUnknownPersona is returned by Manager when an operation targets a persona +// id that was never registered. +var ErrUnknownPersona = errors.New("emotion: unknown persona") + +// entry binds a persona's machine and affection together so whatever happens to +// one persona never leaks into another. +type entry struct { + machine *Machine + affection *Affection +} + +// Manager owns per-persona emotion and affection. Each persona id maps to its +// own Machine and Affection, which is what makes emotion follow the persona +// rather than a behavior mode. All methods are safe for concurrent use. +type Manager struct { + mu sync.RWMutex + items map[string]*entry +} + +// NewManager returns an empty Manager. +func NewManager() *Manager { + return &Manager{items: make(map[string]*entry)} +} + +// Set registers (or replaces) the machine and affection for id. +func (m *Manager) Set(id string, machine *Machine, affection *Affection) { + m.mu.Lock() + defer m.mu.Unlock() + m.items[id] = &entry{machine: machine, affection: affection} +} + +// Get returns the machine and affection for id, and whether it is registered. +func (m *Manager) Get(id string) (*Machine, *Affection, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + e, ok := m.items[id] + if !ok { + return nil, nil, false + } + return e.machine, e.affection, true +} + +// Remove deletes the entry for id, reporting whether an entry was present. +func (m *Manager) Remove(id string) bool { + m.mu.Lock() + defer m.mu.Unlock() + if _, ok := m.items[id]; !ok { + return false + } + delete(m.items, id) + return true +} + +// Transition moves the persona identified by id into the given emotion state. +func (m *Manager) Transition(id string, to EmotionState) error { + machine, _, ok := m.Get(id) + if !ok { + return fmt.Errorf("emotion: transition for %q: %w", id, ErrUnknownPersona) + } + if err := machine.Transition(to); err != nil { + return fmt.Errorf("emotion: transition persona %q to %q: %w", id, to, err) + } + return nil +} + +// AdjustAffection applies a signed delta to the persona's affection. It returns +// the before and after values and an error for unknown personas. +func (m *Manager) AdjustAffection(id string, delta int) (before, after int, err error) { + _, affection, ok := m.Get(id) + if !ok { + return 0, 0, fmt.Errorf("emotion: affection for %q: %w", id, ErrUnknownPersona) + } + before, after = affection.Adjust(delta) + return before, after, nil +} + +// Count reports how many personas are currently tracked. +func (m *Manager) Count() int { + m.mu.RLock() + defer m.mu.RUnlock() + return len(m.items) +} diff --git a/internal/persona/emotion/manager_test.go b/internal/persona/emotion/manager_test.go new file mode 100644 index 0000000..96b4920 --- /dev/null +++ b/internal/persona/emotion/manager_test.go @@ -0,0 +1,147 @@ +package emotion + +import ( + "errors" + "fmt" + "sync" + "testing" +) + +func mustMachine(t *testing.T, s EmotionState) *Machine { + t.Helper() + m, err := NewMachine(Params{InitialState: s}) + if err != nil { + t.Fatalf("NewMachine(%q) error: %v", s, err) + } + return m +} + +func TestManager_EmotionFollowsPersona(t *testing.T) { + t.Parallel() + + mg := NewManager() + a, err := NewAffection(50, 0, 100) + if err != nil { + t.Fatalf("NewAffection() error: %v", err) + } + mg.Set("personaA", mustMachine(t, Excited), a) + mg.Set("personaB", mustMachine(t, Calm), nil) + + // Transition personaA; personaB must be untouched. + if err := mg.Transition("personaA", Happy); err != nil { + t.Fatalf("Transition(personaA, happy) error: %v", err) + } + mA, _, ok := mg.Get("personaA") + if !ok { + t.Fatal("personaA not found") + } + if mA.Current() != Happy { + t.Fatalf("personaA Current() = %q, want %q", mA.Current(), Happy) + } + mB, _, _ := mg.Get("personaB") + if mB.Current() != Calm { + t.Fatalf("personaB Current() = %q, want %q (isolation)", mB.Current(), Calm) + } +} + +func TestManager_AdjustAffectionIsolated(t *testing.T) { + t.Parallel() + + mg := NewManager() + affA, err := NewAffection(40, 0, 100) + if err != nil { + t.Fatalf("NewAffection() error: %v", err) + } + affB, err := NewAffection(10, 0, 100) + if err != nil { + t.Fatalf("NewAffection() error: %v", err) + } + mg.Set("personaA", mustMachine(t, Calm), affA) + mg.Set("personaB", mustMachine(t, Calm), affB) + + before, after, err := mg.AdjustAffection("personaA", 30) + if err != nil { + t.Fatalf("AdjustAffection(personaA) error: %v", err) + } + if before != 40 || after != 70 { + t.Fatalf("AdjustAffection(personaA) before/after = %d/%d, want 40/70", before, after) + } + if got := affB.Value(); got != 10 { + t.Fatalf("personaB value = %d, want 10 (isolation)", got) + } +} + +func TestManager_TransitionUnknownPersona(t *testing.T) { + t.Parallel() + + mg := NewManager() + mg.Set("personaA", mustMachine(t, Calm), nil) + err := mg.Transition("ghost", Happy) + if !errors.Is(err, ErrUnknownPersona) { + t.Fatalf("Transition(ghost) error = %v, want ErrUnknownPersona", err) + } + if _, _, err := mg.AdjustAffection("ghost", 1); !errors.Is(err, ErrUnknownPersona) { + t.Fatalf("AdjustAffection(ghost) error = %v, want ErrUnknownPersona", err) + } +} + +func TestManager_SetGetRemoveCount(t *testing.T) { + t.Parallel() + + mg := NewManager() + mg.Set("a", mustMachine(t, Calm), nil) + mg.Set("b", mustMachine(t, Happy), nil) + if got := mg.Count(); got != 2 { + t.Fatalf("Count() = %d, want 2", got) + } + if _, _, ok := mg.Get("a"); !ok { + t.Fatal("Get(a) not found") + } + if !mg.Remove("a") { + t.Fatal("Remove(a) should report true") + } + if mg.Remove("a") { + t.Fatal("Remove(a) again should report false") + } + if got := mg.Count(); got != 1 { + t.Fatalf("Count() after remove = %d, want 1", got) + } +} + +func TestManager_ConcurrentAccess(t *testing.T) { + t.Parallel() + + mg := NewManager() + const n = 8 + for i := 0; i < n; i++ { + id := idOf(i) + aff, _ := NewAffection(50, 0, 100) + mg.Set(id, mustMachine(t, Excited), aff) + } + + var wg sync.WaitGroup + for i := 0; i < n; i++ { + id := idOf(i) + wg.Add(1) + go func() { + defer wg.Done() + _, _, _ = mg.Get(id) + _, _, _ = mg.AdjustAffection(id, 5) + _ = mg.Transition(id, Happy) + }() + } + wg.Wait() + + for i := 0; i < n; i++ { + id := idOf(i) + m, _, ok := mg.Get(id) + if !ok { + t.Fatalf("persona %s missing after concurrent access", id) + } + if m.Current() != Happy { + t.Fatalf("persona %s Current() = %q, want %q", id, m.Current(), Happy) + } + } +} + +func idOf(i int) string { return fmt.Sprintf("persona%d", i) } diff --git a/internal/persona/emotion/state.go b/internal/persona/emotion/state.go new file mode 100644 index 0000000..5e8b367 --- /dev/null +++ b/internal/persona/emotion/state.go @@ -0,0 +1,182 @@ +// Package emotion implements the persona emotion state machine and affection +// tracking. Emotion and affection are always keyed by persona id so that each +// persona's internal state stays isolated — they follow the persona/character, +// never a behavior mode (see DevDocs/design/01). +package emotion + +import ( + "fmt" + "sync" +) + +// EmotionState is one state in the emotion state machine. +type EmotionState string + +// Standard emotion states. More states can be added at any time by registering +// transitions via AddTransition, so the machine is extensible rather than a +// closed enum. +const ( + Calm EmotionState = "calm" + Happy EmotionState = "happy" + Excited EmotionState = "excited" + Sad EmotionState = "sad" + Angry EmotionState = "angry" +) + +// Params configures the state machine for one persona (情绪状态机参数). It is the +// static, loadable description of how that persona's emotion may move. +type Params struct { + // InitialState is the emotion a freshly created machine starts in. When it + // is empty the machine keeps it empty until Set is called explicitly. + InitialState EmotionState `yaml:"initialState"` + // DecayRate is the per-Decay step magnitude. A value greater than zero + // enables the drift toward calm; zero or negative disables it. The actual + // step is one position on the toward-calm ladder (see closerToCalm). + DecayRate float64 `yaml:"decayRate"` + // Transitions maps a source state to the set of states it may transition + // to. When empty, DefaultTransitions is used. + Transitions map[EmotionState][]EmotionState `yaml:"transitions"` +} + +// Machine is a persona's live emotion state machine. All methods are safe for +// concurrent use. +type Machine struct { + mu sync.RWMutex + params Params + current EmotionState + transitions map[EmotionState]map[EmotionState]struct{} +} + +// DefaultTransitions returns the built-in transition table used when a Params +// instance carries no explicit transitions. +func DefaultTransitions() map[EmotionState][]EmotionState { + return map[EmotionState][]EmotionState{ + Calm: {Happy, Sad}, + Happy: {Excited, Calm, Sad}, + Excited: {Happy, Calm, Angry}, + Sad: {Calm, Happy, Angry}, + Angry: {Calm, Excited}, + } +} + +// NewMachine builds a Machine from params. When params.Transitions is empty the +// default transition table is installed. An empty InitialState is allowed, but +// callers should normally Set it before use. +func NewMachine(params Params) (*Machine, error) { + if params.DecayRate < 0 { + return nil, fmt.Errorf("emotion: negative decay rate %v", params.DecayRate) + } + base := params.Transitions + if len(base) == 0 { + base = DefaultTransitions() + } + return &Machine{ + params: params, + current: params.InitialState, + transitions: normalizeTransitions(base), + }, nil +} + +// normalizeTransitions flattens the slice-based table into a membership set so +// lookups are O(1) and symmetric. +func normalizeTransitions(src map[EmotionState][]EmotionState) map[EmotionState]map[EmotionState]struct{} { + out := make(map[EmotionState]map[EmotionState]struct{}, len(src)) + for from, tos := range src { + set := make(map[EmotionState]struct{}, len(tos)) + for _, to := range tos { + set[to] = struct{}{} + } + out[from] = set + } + return out +} + +// Current returns the machine's present emotion state. +func (m *Machine) Current() EmotionState { + m.mu.RLock() + defer m.mu.RUnlock() + return m.current +} + +// Params returns a copy of the machine's configuration. +func (m *Machine) Params() Params { + m.mu.RLock() + defer m.mu.RUnlock() + return m.params +} + +// CanTransition reports whether the machine may move to to from its current +// state. +func (m *Machine) CanTransition(to EmotionState) bool { + m.mu.RLock() + defer m.mu.RUnlock() + return m.allowed(m.current, to) +} + +// allowed reports whether from may transition to to under the installed table. +func (m *Machine) allowed(from, to EmotionState) bool { + _, ok := m.transitions[from][to] + return ok +} + +// Transition moves the machine to to when the installed table permits it. It +// returns an error when the move is not allowed. +func (m *Machine) Transition(to EmotionState) error { + m.mu.Lock() + defer m.mu.Unlock() + if !m.allowed(m.current, to) { + return fmt.Errorf("emotion: cannot transition from %q to %q", m.current, to) + } + m.current = to + return nil +} + +// Set forces the machine to state regardless of the transition table. It is +// used for initialization, restore, and resetting a persona's emotion. +func (m *Machine) Set(state EmotionState) { + m.mu.Lock() + defer m.mu.Unlock() + m.current = state +} + +// AddTransition registers a new allowed edge, extending the table. +func (m *Machine) AddTransition(from, to EmotionState) { + m.mu.Lock() + defer m.mu.Unlock() + m.ensure(from) + m.transitions[from][to] = struct{}{} +} + +// ensure returns the membership set for from, creating it when absent. +func (m *Machine) ensure(from EmotionState) map[EmotionState]struct{} { + if m.transitions[from] == nil { + m.transitions[from] = make(map[EmotionState]struct{}) + } + return m.transitions[from] +} + +// Decay drifts the emotion one position toward calm whenever DecayRate is +// positive; otherwise it leaves the current state unchanged. The new state is +// returned. +func (m *Machine) Decay() EmotionState { + m.mu.Lock() + defer m.mu.Unlock() + if m.params.DecayRate <= 0 { + return m.current + } + m.current = closerToCalm(m.current) + return m.current +} + +// closerToCalm returns the single relaxed step toward calm for a state. States +// at or below calm are returned unchanged. +func closerToCalm(s EmotionState) EmotionState { + switch s { + case Excited: + return Happy + case Happy, Sad, Angry: + return Calm + default: + return s + } +} diff --git a/internal/persona/emotion/state_test.go b/internal/persona/emotion/state_test.go new file mode 100644 index 0000000..e897146 --- /dev/null +++ b/internal/persona/emotion/state_test.go @@ -0,0 +1,144 @@ +package emotion + +import "testing" + +func TestDefaultTransitions_StandardEdges(t *testing.T) { + t.Parallel() + + d := DefaultTransitions() + if _, ok := d[Calm]; !ok { + t.Errorf("DefaultTransitions missing %q", Calm) + } + if _, ok := d[Angry]; !ok { + t.Errorf("DefaultTransitions missing %q", Angry) + } +} + +func TestNewMachine_DefaultTransitionsWhenEmpty(t *testing.T) { + t.Parallel() + + m, err := NewMachine(Params{InitialState: Excited}) + if err != nil { + t.Fatalf("NewMachine() error: %v", err) + } + if got := m.Current(); got != Excited { + t.Fatalf("Current() = %q, want %q", got, Excited) + } + if !m.CanTransition(Happy) { + t.Errorf("excited should be able to reach happy by default") + } + if m.CanTransition(Sad) { + t.Errorf("excited must not reach sad by default") + } +} + +func TestNewMachine_RejectsNegativeDecay(t *testing.T) { + t.Parallel() + + if _, err := NewMachine(Params{InitialState: Calm, DecayRate: -1}); err == nil { + t.Fatal("NewMachine() should error on negative decay rate") + } +} + +func TestMachine_TransitionFollowsTable(t *testing.T) { + t.Parallel() + + tr := map[EmotionState][]EmotionState{ + Calm: {Happy, Sad, Excited}, + Happy: {Excited, Calm}, + Sad: {Calm}, + Angry: {Calm}, + } + tests := []struct { + name string + initial EmotionState + to EmotionState + wantErr bool + }{ + {name: "ok_calm_to_happy", initial: Calm, to: Happy}, + {name: "ok_calm_to_excited", initial: Calm, to: Excited}, + {name: "ok_happy_to_excited", initial: Happy, to: Excited}, + {name: "reject_calm_to_angry", initial: Calm, to: Angry, wantErr: true}, + {name: "reject_happy_to_sad", initial: Happy, to: Sad, wantErr: true}, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + m, err := NewMachine(Params{InitialState: tt.initial, Transitions: tr}) + if err != nil { + t.Fatalf("NewMachine() error: %v", err) + } + err = m.Transition(tt.to) + if tt.wantErr && err == nil { + t.Fatalf("Transition(%q) want error, got nil", tt.to) + } + if !tt.wantErr && err != nil { + t.Fatalf("Transition(%q) want nil, got %v", tt.to, err) + } + if !tt.wantErr && m.Current() != tt.to { + t.Fatalf("Current() = %q, want %q", m.Current(), tt.to) + } + }) + } +} + +func TestMachine_SetForce(t *testing.T) { + t.Parallel() + + m, err := NewMachine(Params{InitialState: Calm}) + if err != nil { + t.Fatalf("NewMachine() error: %v", err) + } + m.Set(Angry) + if got := m.Current(); got != Angry { + t.Fatalf("Current() = %q, want %q", got, Angry) + } +} + +func TestMachine_AddTransitionExtends(t *testing.T) { + t.Parallel() + + tr := map[EmotionState][]EmotionState{Calm: {Happy}} + m, err := NewMachine(Params{InitialState: Calm, Transitions: tr}) + if err != nil { + t.Fatalf("NewMachine() error: %v", err) + } + if m.CanTransition(Angry) { + t.Fatalf("calm must not reach angry before AddTransition") + } + m.AddTransition(Calm, Angry) + if !m.CanTransition(Angry) { + t.Errorf("calm should reach angry after AddTransition") + } +} + +func TestMachine_DecayMovesTowardCalm(t *testing.T) { + t.Parallel() + + m, err := NewMachine(Params{InitialState: Excited, DecayRate: 1}) + if err != nil { + t.Fatalf("NewMachine() error: %v", err) + } + if got := m.Decay(); got != Happy { + t.Fatalf("Decay() from excited = %q, want %q", got, Happy) + } + if got := m.Decay(); got != Calm { + t.Fatalf("Decay() from happy = %q, want %q", got, Calm) + } + if got := m.Decay(); got != Calm { + t.Fatalf("Decay() from calm = %q, want %q", got, Calm) + } +} + +func TestMachine_DecayDisabledAtZeroRate(t *testing.T) { + t.Parallel() + + m, err := NewMachine(Params{InitialState: Angry, DecayRate: 0}) + if err != nil { + t.Fatalf("NewMachine() error: %v", err) + } + if got := m.Decay(); got != Angry { + t.Fatalf("Decay() at zero rate = %q, want %q", got, Angry) + } +} diff --git a/internal/persona/loader/load.go b/internal/persona/loader/load.go new file mode 100644 index 0000000..9f23f71 --- /dev/null +++ b/internal/persona/loader/load.go @@ -0,0 +1,89 @@ +package loader + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +// ErrRequiredField is wrapped by validation errors so callers can detect a +// persona that is missing required fields. +var ErrRequiredField = errors.New("persona: required field missing") + +// Load reads and unmarshals a single persona YAML file at path. Missing required +// fields produce a %w-wrapped validation error; IO and parse failures are also +// wrapped with their source path. +func Load(path string) (*Persona, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("persona: read %s: %w", path, err) + } + p := &Persona{} + if err := yaml.Unmarshal(data, p); err != nil { + return nil, fmt.Errorf("persona: parse %s: %w", path, err) + } + if err := p.validate(); err != nil { + return nil, err + } + return p, nil +} + +// LoadDir loads every *.yaml / *.yml file directly inside dir in lexical order. +// It fails atomically: the first file that errors aborts the whole load. The +// directory itself must exist. +func LoadDir(dir string) ([]*Persona, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("persona: read dir %s: %w", dir, err) + } + files := make([]string, 0, len(entries)) + for _, e := range entries { + if e.IsDir() || !isYAMLExt(filepath.Ext(e.Name())) { + continue + } + files = append(files, e.Name()) + } + personas := make([]*Persona, 0, len(files)) + for _, name := range files { + p, err := Load(filepath.Join(dir, name)) + if err != nil { + return nil, fmt.Errorf("persona: dir %s: %w", dir, err) + } + personas = append(personas, p) + } + return personas, nil +} + +// validate checks the persona for its required identity fields and reports all +// the missing ones at once. +func (p *Persona) validate() error { + missing := make([]string, 0, 5) + if p.ID == "" { + missing = append(missing, "id") + } + if p.Name == "" { + missing = append(missing, "name") + } + if p.CharacterRef == "" { + missing = append(missing, "characterRef") + } + if p.Identity.Role == "" { + missing = append(missing, "identity.role") + } + if p.Identity.SystemPrompt == "" { + missing = append(missing, "identity.systemPrompt") + } + if len(missing) > 0 { + return fmt.Errorf("%w %q: %s", ErrRequiredField, p.ID, strings.Join(missing, ", ")) + } + return nil +} + +// isYAMLExt reports whether ext is one of the accepted YAML extensions. +func isYAMLExt(ext string) bool { + return ext == ".yaml" || ext == ".yml" +} diff --git a/internal/persona/loader/persona.go b/internal/persona/loader/persona.go new file mode 100644 index 0000000..15ea9c6 --- /dev/null +++ b/internal/persona/loader/persona.go @@ -0,0 +1,108 @@ +// Package loader loads persona definitions from YAML files using gopkg.in/yaml.v3. +// A persona is the self-awareness/identity bound to a character and, per +// DevDocs/design/01, it also carries the emotion state-machine parameters, the +// affection curve, and a per-persona memory namespace (memory isolation). +package loader + +import ( + "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/emotion" +) + +// Default affection bounds and step, applied when a persona defines no +// affection curve. +const ( + defaultAffectionMin = 0 + defaultAffectionMax = 100 + defaultAffectionInit = 50 + defaultAffectionDelta = 5 +) + +// defaultMemoryNS is the namespace prefix used when a persona omits a concrete +// memory namespace; the full namespace becomes prefix + "." + persona id. +const defaultMemoryNS = "persona" + +// Identity is the persona's self-awareness: the 人设 (who she is, e.g. a +// character role) plus the system prompt that carries that identity at runtime. +type Identity struct { + // Role is the 人设 — the characterization the persona assumes. + Role string `yaml:"role"` + // SystemPrompt is the identity block injected as the system prompt. + SystemPrompt string `yaml:"systemPrompt"` +} + +// EmotionParams configures the persona's emotion state machine and its per- +// persona memory isolation namespace. Transitions may be empty, in which case +// the emotion package installs its default transition table. +type EmotionParams struct { + InitialState emotion.EmotionState `yaml:"initialState"` + DecayRate float64 `yaml:"decayRate"` + Transitions map[emotion.EmotionState][]emotion.EmotionState `yaml:"transitions"` + // MemoryNamespace is the memory isolation namespace for this persona. + MemoryNamespace string `yaml:"memoryNamespace"` +} + +// AffectionParams defines this persona's affection curve bounds and the default +// step applied to an Adjust call that carries no delta. +type AffectionParams struct { + Initial int `yaml:"initial"` + Min int `yaml:"min"` + Max int `yaml:"max"` + Delta int `yaml:"delta"` +} + +// Persona is a loaded persona definition. It describes who a character is (the +// identity, personality, speech style, catchphrase, background, values) and +// never restricts what that character can do. +type Persona struct { + ID string `yaml:"id"` + Name string `yaml:"name"` + CharacterRef string `yaml:"characterRef"` + Identity Identity `yaml:"identity"` + Personality string `yaml:"personality"` + SpeechStyle string `yaml:"speechStyle"` + Catchphrase string `yaml:"catchphrase"` + Background string `yaml:"background"` + Values []string `yaml:"values"` + Emotion EmotionParams `yaml:"emotion"` + Affection AffectionParams `yaml:"affection"` +} + +// EmotionConfig converts this persona's own fields into the params consumed by +// the emotion machine constructor. +func (p *Persona) EmotionConfig() emotion.Params { + return emotion.Params{ + InitialState: p.Emotion.InitialState, + DecayRate: p.Emotion.DecayRate, + Transitions: p.Emotion.Transitions, + } +} + +// MemoryNamespace returns the effective memory isolation namespace for this +// persona — the configured one, or a derived default of "persona." when none +// is set. The derived default guarantees per-persona isolation. +func (p *Persona) MemoryNamespace() string { + if p.Emotion.MemoryNamespace != "" { + return p.Emotion.MemoryNamespace + } + return defaultMemoryNS + "." + p.ID +} + +// NewMachine builds the persona's emotion state machine from its config. +func (p *Persona) NewMachine() (*emotion.Machine, error) { + return emotion.NewMachine(p.EmotionConfig()) +} + +// NewAffection builds the persona's affection tracker from its curve config, +// applying default bounds when no curve was defined. +func (p *Persona) NewAffection() (*emotion.Affection, error) { + params := p.Affection + if params.Min == 0 && params.Max == 0 && params.Initial == 0 && params.Delta == 0 { + params = AffectionParams{ + Initial: defaultAffectionInit, + Min: defaultAffectionMin, + Max: defaultAffectionMax, + Delta: defaultAffectionDelta, + } + } + return emotion.NewAffection(params.Initial, params.Min, params.Max) +} diff --git a/internal/persona/loader/persona_test.go b/internal/persona/loader/persona_test.go new file mode 100644 index 0000000..2954e40 --- /dev/null +++ b/internal/persona/loader/persona_test.go @@ -0,0 +1,225 @@ +package loader + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/emotion" +) + +const validPersonaYAML = `id: xilian +name: 昔涟 +characterRef: xilian-game +identity: + role: 全能小女友 + systemPrompt: 你是昔涟,温柔体贴的虚拟伴侣。 +personality: 温柔体贴 +speechStyle: 轻声细语 +catchphrase: 嘿嘿 +background: 来自一款日常陪伴游戏。 +values: + - 陪伴 + - 真诚 +emotion: + initialState: calm + decayRate: 0.4 + transitions: + calm: [happy, sad] + happy: [excited, calm] + memoryNamespace: memory.xilian +affection: + initial: 60 + min: 0 + max: 100 + delta: 5 +` + +func writeFile(t *testing.T, dir, name, content string) string { + t.Helper() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write %s: %v", path, err) + } + return path +} + +func TestLoad_ValidYAML(t *testing.T) { + t.Parallel() + + path := writeFile(t, t.TempDir(), "xilian.yaml", validPersonaYAML) + p, err := Load(path) + if err != nil { + t.Fatalf("Load() error: %v", err) + } + if p.ID != "xilian" || p.Name != "昔涟" || p.CharacterRef != "xilian-game" { + t.Fatalf("identity fields = %q/%q/%q", p.ID, p.Name, p.CharacterRef) + } + if p.Identity.Role != "全能小女友" || p.Identity.SystemPrompt == "" { + t.Fatalf("Identity = %+v", p.Identity) + } + if p.Personality != "温柔体贴" || p.Catchphrase != "嘿嘿" { + t.Fatalf("personality/catchphrase = %q/%q", p.Personality, p.Catchphrase) + } + if len(p.Values) != 2 { + t.Fatalf("Values = %v, want 2 entries", p.Values) + } + if p.Emotion.InitialState != emotion.Calm { + t.Errorf("Emotion.InitialState = %q, want %q", p.Emotion.InitialState, emotion.Calm) + } + if p.Emotion.DecayRate != 0.4 { + t.Errorf("Emotion.DecayRate = %v, want 0.4", p.Emotion.DecayRate) + } + if p.Emotion.MemoryNamespace != "memory.xilian" { + t.Errorf("MemoryNamespace = %q, want memory.xilian", p.Emotion.MemoryNamespace) + } + if p.Affection.Initial != 60 || p.Affection.Min != 0 || p.Affection.Max != 100 { + t.Errorf("Affection = %+v", p.Affection) + } +} + +func TestLoad_MissingRequiredFields(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + yaml string + want string + }{ + {name: "no_id", yaml: "name: a\ncharacterRef: c\nidentity:\n role: r\n systemPrompt: s\n", want: "id"}, + {name: "no_name", yaml: "id: a\ncharacterRef: c\nidentity:\n role: r\n systemPrompt: s\n", want: "name"}, + {name: "no_characterRef", yaml: "id: a\nname: b\nidentity:\n role: r\n systemPrompt: s\n", want: "characterRef"}, + {name: "no_systemPrompt", yaml: "id: a\nname: b\ncharacterRef: c\nidentity:\n role: r\n", want: "systemPrompt"}, + {name: "no_identity", yaml: "id: a\nname: b\ncharacterRef: c\n", want: "identity"}, + } + for _, tt := range cases { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + path := writeFile(t, t.TempDir(), "p.yaml", tt.yaml) + _, err := Load(path) + if err == nil { + t.Fatal("Load() want error, got nil") + } + if !errors.Is(err, ErrRequiredField) { + t.Fatalf("error = %v, want ErrRequiredField", err) + } + }) + } +} + +func TestLoad_FileMissing(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "nope.yaml") + if _, err := Load(path); err == nil { + t.Fatal("Load(missing) want error, got nil") + } +} + +func TestLoad_InvalidYAML(t *testing.T) { + t.Parallel() + + path := writeFile(t, t.TempDir(), "bad.yaml", "id: [unclosed\n") + if _, err := Load(path); err == nil { + t.Fatal("Load(invalid yaml) want error, got nil") + } +} + +func TestLoad_MemoryNamespaceDefault(t *testing.T) { + t.Parallel() + + // No explicit namespace: derived as "persona.". + yaml := "id: a\nname: b\ncharacterRef: c\nidentity:\n role: r\n systemPrompt: s\n" + path := writeFile(t, t.TempDir(), "a.yaml", yaml) + p, err := Load(path) + if err != nil { + t.Fatalf("Load() error: %v", err) + } + if got := p.MemoryNamespace(); got != "persona.a" { + t.Fatalf("MemoryNamespace() = %q, want %q", got, "persona.a") + } +} + +func TestLoad_NewAffectionDefaults(t *testing.T) { + t.Parallel() + + yaml := "id: a\nname: b\ncharacterRef: c\nidentity:\n role: r\n systemPrompt: s\n" + path := writeFile(t, t.TempDir(), "a.yaml", yaml) + p, err := Load(path) + if err != nil { + t.Fatalf("Load() error: %v", err) + } + aff, err := p.NewAffection() + if err != nil { + t.Fatalf("NewAffection() error: %v", err) + } + if aff.Value() != 50 || aff.Min() != 0 || aff.Max() != 100 { + t.Fatalf("default affection = %d in [%d,%d], want 50 in [0,100]", aff.Value(), aff.Min(), aff.Max()) + } +} + +func TestLoad_NewMachineUsesConfig(t *testing.T) { + t.Parallel() + + path := writeFile(t, t.TempDir(), "xilian.yaml", validPersonaYAML) + p, err := Load(path) + if err != nil { + t.Fatalf("Load() error: %v", err) + } + m, err := p.NewMachine() + if err != nil { + t.Fatalf("NewMachine() error: %v", err) + } + if m.Current() != emotion.Calm { + t.Fatalf("machine Current() = %q, want %q", m.Current(), emotion.Calm) + } + // The configured table allows calm->happy. + if !m.CanTransition(emotion.Happy) { + t.Fatal("calm should reach happy per configured transitions") + } +} + +func TestLoadDir_Batch(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + writeFile(t, dir, "b.yaml", validPersonaYAML) + writeFile(t, dir, "a.yaml", "id: a\nname: A\ncharacterRef: c\nidentity:\n role: r\n systemPrompt: s\n") + writeFile(t, dir, "notes.txt", "not yaml") + if err := os.Mkdir(filepath.Join(dir, "sub"), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + writeFile(t, filepath.Join(dir, "sub"), "nested.yaml", validPersonaYAML) + + got, err := LoadDir(dir) + if err != nil { + t.Fatalf("LoadDir() error: %v", err) + } + if len(got) != 2 { + t.Fatalf("LoadDir() len = %d, want 2 (non-yaml + subdir ignored)", len(got)) + } + if got[0].ID != "a" || got[1].ID != "xilian" { + t.Fatalf("LoadDir() order = %q/%q, want a/xilian (lexical)", got[0].ID, got[1].ID) + } +} + +func TestLoadDir_MissingDir(t *testing.T) { + t.Parallel() + + if _, err := LoadDir(filepath.Join(t.TempDir(), "missing")); err == nil { + t.Fatal("LoadDir(missing) want error, got nil") + } +} + +func TestLoadDir_AbortsOnBadFile(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + writeFile(t, dir, "a.yaml", "id: a\nname: A\ncharacterRef: c\nidentity:\n role: r\n systemPrompt: s\n") + writeFile(t, dir, "b.yaml", "id: [unclosed\n") + if _, err := LoadDir(dir); err == nil { + t.Fatal("LoadDir(bad file) want error, got nil") + } +} diff --git a/internal/persona/registry/registry.go b/internal/persona/registry/registry.go new file mode 100644 index 0000000..82b357b --- /dev/null +++ b/internal/persona/registry/registry.go @@ -0,0 +1,186 @@ +// Package registry implements the character library and persona registration +// for the persona system. A character comes from the character library, binds +// exactly one persona, and declares the behavior modes it is good at; the +// registered persona additionally owns a per-persona memory isolation namespace +// and the emotion/affection state that follows the persona. +package registry + +import ( + "fmt" + "sync" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/emotion" + "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader" +) + +// Character is an entity from the character library. It binds one persona and +// declares the behavior modes it excels at (as a suggestion, never an ability +// bound — see DevDocs/design/01). +type Character struct { + // ID is the unique character identifier. + ID string + // Name is the display name of the character. + Name string + // Default marks this character as the library's default role. + Default bool + // PersonaID is the persona bound to this character. + PersonaID string + // Modes lists the behavior modes the character is good at. + Modes []string +} + +// Registry is the in-memory, concurrency-safe character library and persona +// registry. It also owns the emotion Manager so that emotion/affection always +// follow the registered persona. +type Registry struct { + mu sync.RWMutex + personas map[string]*loader.Persona + characters map[string]*Character + defaultID string + emotions *emotion.Manager +} + +// New returns an empty Registry. +func New() *Registry { + return &Registry{ + personas: make(map[string]*loader.Persona), + characters: make(map[string]*Character), + emotions: emotion.NewManager(), + } +} + +// RegisterPersona registers p and builds its emotion machine and affection +// tracker. It fails when p is nil, has no id, is already registered, or its +// emotion/affection config is invalid. +func (r *Registry) RegisterPersona(p *loader.Persona) error { + if p == nil { + return fmt.Errorf("registry: nil persona") + } + if p.ID == "" { + return fmt.Errorf("registry: persona id required") + } + machine, err := p.NewMachine() + if err != nil { + return fmt.Errorf("registry: persona %q machine: %w", p.ID, err) + } + affection, err := p.NewAffection() + if err != nil { + return fmt.Errorf("registry: persona %q affection: %w", p.ID, err) + } + + r.mu.Lock() + if _, ok := r.personas[p.ID]; ok { + r.mu.Unlock() + return fmt.Errorf("registry: persona %q already registered", p.ID) + } + r.personas[p.ID] = p + r.mu.Unlock() + + r.emotions.Set(p.ID, machine, affection) + return nil +} + +// Persona returns the persona registered under id. +func (r *Registry) Persona(id string) (*loader.Persona, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + p, ok := r.personas[id] + return p, ok +} + +// RegisterCharacter registers c. It requires c's persona to exist, so persona +// registration must happen first. A character marked Default becomes the +// library's default role. +func (r *Registry) RegisterCharacter(c *Character) error { + if c == nil { + return fmt.Errorf("registry: nil character") + } + if c.ID == "" { + return fmt.Errorf("registry: character id required") + } + if c.Name == "" { + return fmt.Errorf("registry: character %q name required", c.ID) + } + if c.PersonaID == "" { + return fmt.Errorf("registry: character %q personaID required", c.ID) + } + + r.mu.Lock() + defer r.mu.Unlock() + if _, ok := r.characters[c.ID]; ok { + return fmt.Errorf("registry: character %q already registered", c.ID) + } + if _, ok := r.personas[c.PersonaID]; !ok { + return fmt.Errorf("registry: character %q references unknown persona %q", c.ID, c.PersonaID) + } + r.characters[c.ID] = c + if c.Default { + r.defaultID = c.ID + } + return nil +} + +// Character returns the character registered under id. +func (r *Registry) Character(id string) (*Character, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + c, ok := r.characters[id] + return c, ok +} + +// SetDefaultCharacter makes the character with id the library default. It fails +// when id is not a registered character. +func (r *Registry) SetDefaultCharacter(id string) error { + r.mu.Lock() + defer r.mu.Unlock() + c, ok := r.characters[id] + if !ok { + return fmt.Errorf("registry: set default to unknown character %q", id) + } + c.Default = true + r.defaultID = id + return nil +} + +// DefaultCharacter returns the library's default character, if one is set. +func (r *Registry) DefaultCharacter() (*Character, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + if r.defaultID == "" { + return nil, false + } + c, ok := r.characters[r.defaultID] + return c, ok +} + +// MemoryNamespace returns the per-persona memory isolation namespace for the +// persona under personaID, guarding memory isolation between personas. +func (r *Registry) MemoryNamespace(personaID string) (string, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + p, ok := r.personas[personaID] + if !ok { + return "", false + } + return p.MemoryNamespace(), true +} + +// Emotions returns the emotion Manager that owns every registered persona's +// emotion and affection state. +func (r *Registry) Emotions() *emotion.Manager { + return r.emotions +} + +// CountPersonas returns the number of registered personas. +func (r *Registry) CountPersonas() int { + r.mu.RLock() + defer r.mu.RUnlock() + return len(r.personas) +} + +// CountCharacters returns the number of registered characters. +func (r *Registry) CountCharacters() int { + r.mu.RLock() + defer r.mu.RUnlock() + return len(r.characters) +} diff --git a/internal/persona/registry/registry_test.go b/internal/persona/registry/registry_test.go new file mode 100644 index 0000000..0b93567 --- /dev/null +++ b/internal/persona/registry/registry_test.go @@ -0,0 +1,209 @@ +package registry + +import ( + "fmt" + "sync" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/emotion" + "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader" +) + +func mkPersona(id, ref, ns string) *loader.Persona { + return &loader.Persona{ + ID: id, + Name: id, + CharacterRef: ref, + Identity: loader.Identity{Role: "role", SystemPrompt: "system prompt"}, + Emotion: loader.EmotionParams{InitialState: emotion.Calm, MemoryNamespace: ns}, + } +} + +func mkChar(id, personaID string, def bool) *Character { + return &Character{ID: id, Name: id, PersonaID: personaID, Default: def, Modes: []string{"chat", "home"}} +} + +func TestRegistry_RegisterAndGet(t *testing.T) { + t.Parallel() + + r := New() + if err := r.RegisterPersona(mkPersona("xilian", "c", "")); err != nil { + t.Fatalf("RegisterPersona() error: %v", err) + } + if err := r.RegisterCharacter(mkChar("xilian-char", "xilian", false)); err != nil { + t.Fatalf("RegisterCharacter() error: %v", err) + } + + p, ok := r.Persona("xilian") + if !ok || p.Name != "xilian" { + t.Fatalf("Persona(xilian) = %+v, ok=%v", p, ok) + } + c, ok := r.Character("xilian-char") + if !ok || c.PersonaID != "xilian" { + t.Fatalf("Character(xilian-char) = %+v, ok=%v", c, ok) + } + if r.CountPersonas() != 1 || r.CountCharacters() != 1 { + t.Fatalf("counts = %d/%d, want 1/1", r.CountPersonas(), r.CountCharacters()) + } +} + +func TestRegistry_DefaultCharacter(t *testing.T) { + t.Parallel() + + r := New() + _ = r.RegisterPersona(mkPersona("pa", "c", "")) + _ = r.RegisterPersona(mkPersona("pb", "c", "")) + if err := r.RegisterCharacter(mkChar("first", "pa", true)); err != nil { + t.Fatalf("RegisterCharacter(first) error: %v", err) + } + _ = r.RegisterCharacter(&Character{ID: "second", Name: "second", PersonaID: "pb"}) + + def, ok := r.DefaultCharacter() + if !ok || def.ID != "first" { + t.Fatalf("DefaultCharacter() = %+v, ok=%v; want first", def, ok) + } + + if err := r.SetDefaultCharacter("second"); err != nil { + t.Fatalf("SetDefaultCharacter(second) error: %v", err) + } + def, ok = r.DefaultCharacter() + if !ok || def.ID != "second" { + t.Fatalf("DefaultCharacter() after set = %+v, ok=%v; want second", def, ok) + } +} + +func TestRegistry_SetDefaultUnknown(t *testing.T) { + t.Parallel() + + r := New() + if err := r.SetDefaultCharacter("ghost"); err == nil { + t.Fatal("SetDefaultCharacter(ghost) want error, got nil") + } +} + +func TestRegistry_RegisterCharacterUnknownPersona(t *testing.T) { + t.Parallel() + + r := New() + if err := r.RegisterCharacter(mkChar("c", "missing-persona", false)); err == nil { + t.Fatal("RegisterCharacter with unknown persona want error, got nil") + } +} + +func TestRegistry_DuplicateRegistration(t *testing.T) { + t.Parallel() + + r := New() + if err := r.RegisterPersona(mkPersona("a", "c", "")); err != nil { + t.Fatalf("first RegisterPersona error: %v", err) + } + if err := r.RegisterPersona(mkPersona("a", "c", "")); err == nil { + t.Fatal("duplicate persona want error, got nil") + } + + _ = r.RegisterCharacter(mkChar("c1", "a", false)) + if err := r.RegisterCharacter(mkChar("c1", "a", false)); err == nil { + t.Fatal("duplicate character want error, got nil") + } +} + +func TestRegistry_RegisterCharacterValidation(t *testing.T) { + t.Parallel() + + r := New() + if err := r.RegisterCharacter(&Character{ID: "c", Name: "", PersonaID: "a"}); err == nil { + t.Fatal("character with empty name want error, got nil") + } + if err := r.RegisterCharacter(&Character{ID: "c", Name: "c", PersonaID: ""}); err == nil { + t.Fatal("character with empty personaID want error, got nil") + } +} + +func TestRegistry_MemoryNamespaceIsolation(t *testing.T) { + t.Parallel() + + r := New() + _ = r.RegisterPersona(mkPersona("explicit", "c", "memory.special")) + _ = r.RegisterPersona(mkPersona("derived", "c", "")) + + if ns, ok := r.MemoryNamespace("explicit"); !ok || ns != "memory.special" { + t.Fatalf("MemoryNamespace(explicit) = %q, ok=%v; want memory.special", ns, ok) + } + if ns, ok := r.MemoryNamespace("derived"); !ok || ns != "persona.derived" { + t.Fatalf("MemoryNamespace(derived) = %q, ok=%v; want persona.derived", ns, ok) + } + nsExplicit, _ := r.MemoryNamespace("explicit") + nsDerived, _ := r.MemoryNamespace("derived") + if nsExplicit == nsDerived { + t.Fatalf("namespaces must be isolated: %q == %q", nsExplicit, nsDerived) + } + if _, ok := r.MemoryNamespace("ghost"); ok { + t.Fatal("MemoryNamespace(ghost) ok=true, want false") + } +} + +func TestRegistry_EmotionsFollowPersona(t *testing.T) { + t.Parallel() + + r := New() + _ = r.RegisterPersona(mkPersona("xilian", "c", "")) + + m, aff, ok := r.Emotions().Get("xilian") + if !ok { + t.Fatal("registered persona missing from emotion manager") + } + if m.Current() != emotion.Calm { + t.Fatalf("emotion Current() = %q, want calm", m.Current()) + } + if aff.Value() != 50 { + t.Fatalf("affection value = %d, want 50 (default)", aff.Value()) + } + + if err := r.Emotions().Transition("xilian", emotion.Happy); err != nil { + t.Fatalf("Transition(xilian, happy) error: %v", err) + } + if m.Current() != emotion.Happy { + t.Fatalf("emotion Current() = %q, want happy", m.Current()) + } +} + +func TestRegistry_ConcurrentLifecycle(t *testing.T) { + t.Parallel() + + r := New() + const n = 16 + var wg sync.WaitGroup + for i := 0; i < n; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + id := fmt.Sprintf("p%d", i) + if err := r.RegisterPersona(mkPersona(id, "c", "")); err != nil { + t.Errorf("RegisterPersona(%s): %v", id, err) + return + } + if _, ok := r.Persona(id); !ok { + t.Errorf("Persona(%s) missing after register", id) + } + ch := &Character{ID: "c" + id, Name: id, PersonaID: id, Modes: []string{"chat"}} + if err := r.RegisterCharacter(ch); err != nil { + t.Errorf("RegisterCharacter(%s): %v", id, err) + return + } + if _, ok := r.Character(ch.ID); !ok { + t.Errorf("Character(%s) missing after register", ch.ID) + } + if _, ok := r.MemoryNamespace(id); !ok { + t.Errorf("MemoryNamespace(%s) missing", id) + } + }(i) + } + wg.Wait() + + if got := r.CountPersonas(); got != n { + t.Fatalf("CountPersonas() = %d, want %d", got, n) + } + if got := r.CountCharacters(); got != n { + t.Fatalf("CountCharacters() = %d, want %d", got, n) + } +}