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