feat(persona): 人格系统(YAML loader / 角色人格注册+记忆隔离 / 情绪状态机+好感度)

This commit is contained in:
Aska committed 2026-09-06 04:36:31 +08:00
1 parent 653c2489bf
commit 70fd915242
11 files changed
+1568

No files matched your search

+104
View File
@@ -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())
}
}
+90
View File
@@ -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)
}
+147
View File
@@ -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) }
+182
View File
@@ -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
}
}
+144
View File
@@ -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)
}
}