// 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] } // Move aims the machine at target, walking the persona's installed transition // table. A direct edge is taken when present; otherwise the first hop of a // shortest legal path is taken, so a caller can aim at an emotion without // knowing the intermediate states. A target that is unreachable from the current // state, an empty target, or the current state itself leaves the machine // unchanged rather than forcing an illegal move. The resulting state is // returned. func (m *Machine) Move(target EmotionState) EmotionState { m.mu.Lock() defer m.mu.Unlock() if target == "" || target == m.current { return m.current } if m.allowed(m.current, target) { m.current = target return m.current } if hop, ok := m.firstHopTo(target); ok { m.current = hop } return m.current } // firstHopTo returns the first state on a shortest transition path from the // current state to target, and whether such a path exists. Ties between equally // short paths are broken arbitrarily, which is invisible to callers: any first // hop on a shortest legal path moves the machine in the wanted direction. func (m *Machine) firstHopTo(target EmotionState) (EmotionState, bool) { type node struct { state EmotionState first EmotionState } queue := make([]node, 0, len(m.transitions[m.current])) for next := range m.transitions[m.current] { if next == target { return next, true } queue = append(queue, node{state: next, first: next}) } seen := map[EmotionState]struct{}{m.current: {}} for len(queue) > 0 { cur := queue[0] queue = queue[1:] if _, ok := seen[cur.state]; ok { continue } seen[cur.state] = struct{}{} for next := range m.transitions[cur.state] { if next == target { return cur.first, true } queue = append(queue, node{state: next, first: cur.first}) } } return "", false } // 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 } }