feat(persona): 人格系统(YAML loader / 角色人格注册+记忆隔离 / 情绪状态机+好感度)
This commit is contained in:
1 parent
653c2489bf
commit
70fd915242
11 files changed
+1568
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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.<id>" 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)
|
||||
}
|
||||
@@ -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.<id>".
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user