Files
Cyrene_v2/internal/persona/emotion/state.go
T

183 lines
5.6 KiB
Go

// 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
}
}