package emotion import "sync" // Update reports the effect of one observed turn on a persona's emotion and // affection. It is handed to the Observer's emit sink so a caller can surface // the change (the `mood` WS event) and log it. type Update struct { // PersonaID is the persona the turn was applied to. PersonaID string // Before is the emotion state before the turn. Before EmotionState // After is the emotion state after the turn. After EmotionState // Affection is the affection value after the turn. Affection int // Signal reports whether the turn carried an emotional cue. Signal bool } // Moved reports whether the emotion state actually changed this turn. It is the // gate for broadcasting: a turn that leaves the mood where it was is not news. func (u Update) Moved() bool { return u.Before != u.After } // StepFunc returns the affection magnitude configured for a persona (its 好感度 // 曲线 delta, design/01 §2). Returning 0 leaves affection untouched. type StepFunc func(personaID string) int // Observer applies settled conversation turns to the per-persona emotion machine // and affection. It owns no state of its own beyond the write lock: the machines // and affection trackers live in the Manager, which is what keeps emotion keyed // by persona (design/01 §2: emotion/affection follow the persona/character, never // a behavior mode). All methods are safe for concurrent use. type Observer struct { mu sync.Mutex mgr *Manager step StepFunc emit func(Update) } // NewObserver builds an Observer over mgr. step resolves the per-persona // affection magnitude (a nil step means affection never moves); emit, when // non-nil, is called once per mood change so a caller can broadcast it. func NewObserver(mgr *Manager, step StepFunc, emit func(Update)) *Observer { if step == nil { step = func(string) int { return 0 } } return &Observer{mgr: mgr, step: step, emit: emit} } // Observe applies one settled turn's user text to personaID's emotion machine // and affection, then returns the persona's mood after the turn ("" when the // persona is unknown or the conversation is not writable). // // writable is the conversation's write gate: a restricted (group/stranger) // conversation is read-only for emotion exactly as it is for memory, so a third // party can never move the persona's mood or affection (design/02 §3, design/11 // §2 — memory/emotion isolation; identity stays structural, never prompt-based). // // A turn with an emotional cue moves the machine one legal edge toward the state // the cue aims at (Machine.Move walks the persona's own transition table, and the // step size is one edge per turn — the ladder is the machine's, not a new // intensity scale) and steps the affection curve; a turn with no cue instead lets // the emotion decay one step toward calm (Machine.Decay, gated by the persona's // decayRate). One Run = one turn = one application, so nothing is applied twice. func (o *Observer) Observe(personaID, userText string, writable bool) EmotionState { if !writable || personaID == "" { return "" } machine, affection, ok := o.mgr.Get(personaID) if !ok { return "" } o.mu.Lock() defer o.mu.Unlock() before := machine.Current() reaction := React(userText) if reaction.Signal { machine.Move(reaction.State) if affection != nil && reaction.AffectionDelta != 0 { affection.Adjust(reaction.AffectionDelta * o.step(personaID)) } } else { machine.Decay() } after := machine.Current() value := 0 if affection != nil { value = affection.Value() } update := Update{PersonaID: personaID, Before: before, After: after, Affection: value, Signal: reaction.Signal} if o.emit != nil && update.Moved() { o.emit(update) } return after }