feat(emotion): A1 情绪系统接线 + refactor: 拆分 channelbridge.go
A1 情绪接线: - loop 回合边界钩子(TurnEnd/OnTurnEnd/settleTurn, Result.Mood); 每轮一次非每步 - persona/emotion: React(valence五档) + Machine.Move(BFS最短合法路径一步) + Observer(observe/decay/好感度,仅变化才emit); 好感度步长读角色配置(cyrene delta=10, 0..700) - 落库 Mood(web session_persist.res.Mood + IM channelbridge/persist; user消息不带mood); WS mood事件(BroadcastMood,仅变化); per-persona隔离; 群聊/陌生人只读不写不发 - cmd/cyrene/emotion.go 生产接线 + wire.go 注入 loop.Options.OnTurnEnd - 22 测试(状态机被loop调用/Mood非空落库/mood帧shape/只变化才发/群聊只读) + 真二进制冒烟 - Docs: api-client v0.32 §3.7 情绪事件 + 开源对照清单(companion 应用对照) - 诚实: 本机无LLM凭据→未活体验证'模型返回后完整轮次'一环 拆分 channelbridge.go 816→257(纯搬移零逻辑): connect/inbound/outbound/blacklist/helpers.go; A1 的 IM mood 调用点落在新 inbound.go, 故与本提交同批 回滚 6 个 gofmt 顺手格式化文件(capacity_loop_test/flow/source_test/findchannel_test/admin_channels_platform_test/history) 缩 diff, 含 history.go 注释被转弯引号
This commit is contained in:
1 parent
610b330025
commit
1a85de555d
27 files changed
+1946
-594
No files matched your search
@@ -0,0 +1,98 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package emotion
|
||||
|
||||
import "testing"
|
||||
|
||||
// newTestObserver builds an observer over one persona whose affection step is 10,
|
||||
// capturing every emitted update.
|
||||
func newTestObserver(t *testing.T, id string, decay float64, initial EmotionState) (*Observer, *Machine, *Affection, *[]Update) {
|
||||
t.Helper()
|
||||
machine, err := NewMachine(Params{InitialState: initial, DecayRate: decay})
|
||||
if err != nil {
|
||||
t.Fatalf("NewMachine: %v", err)
|
||||
}
|
||||
affection, err := NewAffection(50, 0, 700)
|
||||
if err != nil {
|
||||
t.Fatalf("NewAffection: %v", err)
|
||||
}
|
||||
mgr := NewManager()
|
||||
mgr.Set(id, machine, affection)
|
||||
|
||||
emitted := &[]Update{}
|
||||
obs := NewObserver(mgr, func(string) int { return 10 }, func(u Update) {
|
||||
*emitted = append(*emitted, u)
|
||||
})
|
||||
return obs, machine, affection, emitted
|
||||
}
|
||||
|
||||
// TestObserveMovesEmotionAndAffection locks the turn rule: a warm turn raises the
|
||||
// mood toward happy/excited and steps affection up by the persona's configured
|
||||
// magnitude, a cold turn lowers both, and the change is reported for broadcast.
|
||||
func TestObserveMovesEmotionAndAffection(t *testing.T) {
|
||||
t.Parallel()
|
||||
obs, machine, affection, emitted := newTestObserver(t, "cyrene", 0.5, Calm)
|
||||
|
||||
if got := obs.Observe("cyrene", "今天谢谢你呀", true); got != Happy {
|
||||
t.Fatalf("warm turn mood = %s, want happy", got)
|
||||
}
|
||||
if affection.Value() != 60 {
|
||||
t.Fatalf("affection after warm turn = %d, want 60 (50+10)", affection.Value())
|
||||
}
|
||||
if got := obs.Observe("cyrene", "我今天好累,有点难过", true); got != Sad {
|
||||
t.Fatalf("down turn mood = %s, want sad", got)
|
||||
}
|
||||
if affection.Value() != 50 {
|
||||
t.Fatalf("affection after down turn = %d, want 50 (60-10)", affection.Value())
|
||||
}
|
||||
// A cue-free turn carries no signal: the emotion decays toward calm instead of
|
||||
// jumping, so sad → calm.
|
||||
if got := obs.Observe("cyrene", "帮我看下这个报错", true); got != Calm {
|
||||
t.Fatalf("neutral turn mood = %s, want decayed calm", got)
|
||||
}
|
||||
if len(*emitted) != 3 {
|
||||
t.Fatalf("emitted = %d, want 3 (one per real mood change)", len(*emitted))
|
||||
}
|
||||
if machine.Current() != Calm {
|
||||
t.Fatalf("machine current = %s, want calm", machine.Current())
|
||||
}
|
||||
}
|
||||
|
||||
// TestObserveEmitsOnlyOnChange locks the noise guard: a turn that leaves the mood
|
||||
// where it already was emits nothing, while a turn that moves it emits once.
|
||||
func TestObserveEmitsOnlyOnChange(t *testing.T) {
|
||||
t.Parallel()
|
||||
obs, _, _, emitted := newTestObserver(t, "cyrene", 0.5, Calm)
|
||||
|
||||
// calm + neutral → calm: no change, no event.
|
||||
if got := obs.Observe("cyrene", "把这段代码格式化一下", true); got != Calm {
|
||||
t.Fatalf("neutral turn mood = %s, want calm", got)
|
||||
}
|
||||
if len(*emitted) != 0 {
|
||||
t.Fatalf("emitted = %d, want 0 for an unchanged mood", len(*emitted))
|
||||
}
|
||||
// calm + warm → happy: one event.
|
||||
obs.Observe("cyrene", "谢谢你", true)
|
||||
// happy + warm → happy: unchanged, no second event.
|
||||
obs.Observe("cyrene", "辛苦啦", true)
|
||||
if len(*emitted) != 1 {
|
||||
t.Fatalf("emitted = %d, want 1 (only the real change)", len(*emitted))
|
||||
}
|
||||
}
|
||||
|
||||
// TestObserveDecayDisabledByRate locks the persona's decayRate knob: with decay
|
||||
// off, a cue-free turn leaves the mood untouched.
|
||||
func TestObserveDecayDisabledByRate(t *testing.T) {
|
||||
t.Parallel()
|
||||
obs, machine, _, emitted := newTestObserver(t, "cyrene", 0, Happy)
|
||||
|
||||
if got := obs.Observe("cyrene", "帮我把日志改成 debug", true); got != Happy {
|
||||
t.Fatalf("mood with decay disabled = %s, want happy unchanged", got)
|
||||
}
|
||||
if machine.Current() != Happy {
|
||||
t.Fatalf("machine current = %s, want happy", machine.Current())
|
||||
}
|
||||
if len(*emitted) != 0 {
|
||||
t.Fatalf("emitted = %d, want 0", len(*emitted))
|
||||
}
|
||||
}
|
||||
|
||||
// TestObserveRestrictedConversationIsReadOnly locks the isolation boundary: a
|
||||
// group-chat/stranger turn (writable=false) never moves the persona's mood or
|
||||
// affection, mirroring the read-only memory lane (design/02 §3, design/11 §2).
|
||||
func TestObserveRestrictedConversationIsReadOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
obs, machine, affection, emitted := newTestObserver(t, "cyrene", 0.5, Calm)
|
||||
|
||||
if got := obs.Observe("cyrene", "我很喜欢你", false); got != "" {
|
||||
t.Fatalf("restricted turn mood = %q, want empty", got)
|
||||
}
|
||||
if machine.Current() != Calm || affection.Value() != 50 {
|
||||
t.Fatalf("restricted turn changed state to %s/%d, want calm/50", machine.Current(), affection.Value())
|
||||
}
|
||||
if len(*emitted) != 0 {
|
||||
t.Fatalf("emitted = %d, want 0 for a restricted turn", len(*emitted))
|
||||
}
|
||||
}
|
||||
|
||||
// TestObserveUnknownPersonaIsNoop locks the persona-scoping guard: an unregistered
|
||||
// persona id yields no mood and no panic.
|
||||
func TestObserveUnknownPersonaIsNoop(t *testing.T) {
|
||||
t.Parallel()
|
||||
obs, machine, _, _ := newTestObserver(t, "cyrene", 0.5, Calm)
|
||||
|
||||
if got := obs.Observe("someone-else", "我爱你", true); got != "" {
|
||||
t.Fatalf("unknown persona mood = %q, want empty", got)
|
||||
}
|
||||
if machine.Current() != Calm {
|
||||
t.Fatalf("unknown persona id moved the registered machine to %s", machine.Current())
|
||||
}
|
||||
if got := obs.Observe("", "我爱你", true); got != "" {
|
||||
t.Fatalf("empty persona mood = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestObserveKeepsPersonasSeparate locks the per-persona isolation the whole
|
||||
// mechanism rests on (design/01 §2: emotion follows the persona): a turn applied
|
||||
// to one persona leaves every other persona untouched.
|
||||
func TestObserveKeepsPersonasSeparate(t *testing.T) {
|
||||
t.Parallel()
|
||||
aMachine, err := NewMachine(Params{InitialState: Calm, DecayRate: 0.5})
|
||||
if err != nil {
|
||||
t.Fatalf("NewMachine: %v", err)
|
||||
}
|
||||
aAffection, err := NewAffection(50, 0, 700)
|
||||
if err != nil {
|
||||
t.Fatalf("NewAffection: %v", err)
|
||||
}
|
||||
bMachine, err := NewMachine(Params{InitialState: Calm, DecayRate: 0.5})
|
||||
if err != nil {
|
||||
t.Fatalf("NewMachine: %v", err)
|
||||
}
|
||||
bAffection, err := NewAffection(50, 0, 700)
|
||||
if err != nil {
|
||||
t.Fatalf("NewAffection: %v", err)
|
||||
}
|
||||
mgr := NewManager()
|
||||
mgr.Set("a", aMachine, aAffection)
|
||||
mgr.Set("b", bMachine, bAffection)
|
||||
obs := NewObserver(mgr, func(string) int { return 10 }, nil)
|
||||
|
||||
if got := obs.Observe("a", "我爱你", true); got != Happy {
|
||||
t.Fatalf("persona a mood = %s, want happy", got)
|
||||
}
|
||||
if bMachine.Current() != Calm || bAffection.Value() != 50 {
|
||||
t.Fatalf("persona b = %s/%d, want calm/50 untouched", bMachine.Current(), bAffection.Value())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package emotion
|
||||
|
||||
import "strings"
|
||||
|
||||
// Reaction is one turn's emotional reading of the user's message (design/01 §2:
|
||||
// the persona config carries the emotion state-machine parameters and the
|
||||
// affection curve; the machine itself only knows states and legal moves).
|
||||
//
|
||||
// design/01 fixes the model (states + transitions + decay + affection curve) but
|
||||
// does not enumerate the trigger vocabulary, so the minimal reading implemented
|
||||
// here is: a clearly angry turn, a clearly down turn, a strongly positive turn
|
||||
// and a mildly positive turn, each moving the matching state and stepping the
|
||||
// affection curve once. A turn with no cue carries no signal, which leaves the
|
||||
// emotion to decay toward calm instead of jumping (see Observer.Observe).
|
||||
type Reaction struct {
|
||||
// State is the emotion state this turn moves the machine toward. It is a
|
||||
// target, not a forced value: the machine walks its configured transition
|
||||
// table toward it (Machine.Move), so a persona's own table always wins.
|
||||
State EmotionState
|
||||
// AffectionDelta is the signed number of affection *steps* for this turn
|
||||
// (+1 warm, -1 cold, 0 neutral). The concrete magnitude is the persona's
|
||||
// configured affection curve step, applied by the caller.
|
||||
AffectionDelta int
|
||||
// Signal reports whether the turn carried any emotional cue at all.
|
||||
Signal bool
|
||||
}
|
||||
|
||||
// React classifies one user turn into the persona's emotional response. Anger
|
||||
// and negative cues win over positive ones, so a mixed message ("不开心" also
|
||||
// contains "开心") never reads as positive.
|
||||
func React(text string) Reaction {
|
||||
t := strings.ToLower(text)
|
||||
switch {
|
||||
case t == "":
|
||||
return Reaction{}
|
||||
case containsCue(t, angerCues()):
|
||||
return Reaction{State: Angry, AffectionDelta: affectionDown, Signal: true}
|
||||
case containsCue(t, negativeCues()):
|
||||
return Reaction{State: Sad, AffectionDelta: affectionDown, Signal: true}
|
||||
case containsCue(t, strongPositiveCues()):
|
||||
return Reaction{State: Excited, AffectionDelta: affectionUp, Signal: true}
|
||||
case containsCue(t, positiveCues()):
|
||||
return Reaction{State: Happy, AffectionDelta: affectionUp, Signal: true}
|
||||
default:
|
||||
return Reaction{}
|
||||
}
|
||||
}
|
||||
|
||||
// Affection step signs. The magnitude comes from the persona's own curve.
|
||||
const (
|
||||
affectionUp = 1
|
||||
affectionDown = -1
|
||||
)
|
||||
|
||||
// containsCue reports whether text contains any of the cues.
|
||||
func containsCue(text string, cues []string) bool {
|
||||
for _, c := range cues {
|
||||
if strings.Contains(text, c) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// angerCues are the protest/hostility cues that move the persona to angry. They
|
||||
// are checked first so an angry turn is never read as merely sad or happy.
|
||||
func angerCues() []string {
|
||||
return []string{
|
||||
"讨厌你", "烦你", "恨你", "别烦", "好烦", "烦死", "烦人", "生气", "气死", "气愤",
|
||||
"滚开", "走开", "闭嘴", "不理你", "分手", "凶我", "骂你",
|
||||
"angry", "annoying", "annoyed", "shut up", "hate you", "mad at",
|
||||
}
|
||||
}
|
||||
|
||||
// negativeCues are the down/low cues that move the persona to sad — including
|
||||
// tiredness, which in the five built-in states reads as the gentle/caring lane.
|
||||
func negativeCues() []string {
|
||||
return []string{
|
||||
"不开心", "很难过", "难过", "伤心", "委屈", "失望", "沮丧", "郁闷", "崩溃", "心累",
|
||||
"累了", "好累", "疲惫", "困了", "想哭", "呜呜", "555", "糟糕", "焦虑", "孤独",
|
||||
"孤单", "寂寞", "失眠", "睡不着", "被冷落",
|
||||
"sad", "tired", "upset", "exhausted", "lonely", "depressed", "feel bad",
|
||||
}
|
||||
}
|
||||
|
||||
// strongPositiveCues are the high-arousal warm cues that move the persona to
|
||||
// excited (a stronger state than happy).
|
||||
func strongPositiveCues() []string {
|
||||
return []string{
|
||||
"太开心", "好开心", "最喜欢", "太喜欢", "爱死", "太棒", "好耶", "好激动",
|
||||
"兴奋", "幸福死了", "amazing", "awesome", "so happy", "love you",
|
||||
}
|
||||
}
|
||||
|
||||
// positiveCues are the everyday warm cues that move the persona to happy.
|
||||
func positiveCues() []string {
|
||||
return []string{
|
||||
"开心", "高兴", "喜欢", "爱你", "想你", "抱抱", "么么", "亲亲", "摸摸", "谢谢",
|
||||
"辛苦", "哈哈", "嘿嘿", "嘻嘻", "真好", "厉害", "好棒", "期待", "满足", "幸福",
|
||||
"好呀", "好哒", "早安", "早上好", "中午好", "下午好", "晚上好", "晚安",
|
||||
"nice", "great", "thanks", "thank you", "haha", "hug", "miss you",
|
||||
"good morning", "good night", "good evening",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package emotion
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestReactClassifiesTurns locks the turn reading the observer applies: anger and
|
||||
// negative cues outrank positive ones, strong warmth outranks mild warmth, and a
|
||||
// cue-free turn carries no signal (so the machine is left to decay, not jump).
|
||||
func TestReactClassifiesTurns(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
text string
|
||||
state EmotionState
|
||||
delta int
|
||||
sig bool
|
||||
}{
|
||||
{name: "empty", text: "", sig: false},
|
||||
{name: "plain task", text: "帮我把这个文件重命名一下", sig: false},
|
||||
{name: "mild warm", text: "今天谢谢你呀", state: Happy, delta: affectionUp, sig: true},
|
||||
{name: "greeting", text: "早上好", state: Happy, delta: affectionUp, sig: true},
|
||||
{name: "strong warm", text: "我太开心了,最喜欢你了", state: Excited, delta: affectionUp, sig: true},
|
||||
{name: "down", text: "今天有点难过", state: Sad, delta: affectionDown, sig: true},
|
||||
{name: "tired", text: "我好累", state: Sad, delta: affectionDown, sig: true},
|
||||
{name: "angry", text: "别烦我了", state: Angry, delta: affectionDown, sig: true},
|
||||
// "不开心" contains "开心": the negative reading must win, otherwise a
|
||||
// complaint would raise the mood.
|
||||
{name: "negated positive", text: "我不开心", state: Sad, delta: affectionDown, sig: true},
|
||||
{name: "ascii warm", text: "thanks, miss you", state: Happy, delta: affectionUp, sig: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := React(tc.text)
|
||||
if got.Signal != tc.sig {
|
||||
t.Fatalf("React(%q).Signal = %v, want %v", tc.text, got.Signal, tc.sig)
|
||||
}
|
||||
if !tc.sig {
|
||||
return
|
||||
}
|
||||
if got.State != tc.state || got.AffectionDelta != tc.delta {
|
||||
t.Fatalf("React(%q) = {%s, %d}, want {%s, %d}", tc.text, got.State, got.AffectionDelta, tc.state, tc.delta)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMachineMoveWalksTransitions verifies Move honors the installed transition
|
||||
// table instead of forcing a state: an installed edge is taken directly, and an
|
||||
// emotion two edges away is reached through the legal intermediate (the default
|
||||
// table has no calm→angry or calm→excited edge).
|
||||
func TestMachineMoveWalksTransitions(t *testing.T) {
|
||||
t.Parallel()
|
||||
m, err := NewMachine(Params{InitialState: Calm})
|
||||
if err != nil {
|
||||
t.Fatalf("NewMachine: %v", err)
|
||||
}
|
||||
|
||||
if got := m.Move(Happy); got != Happy {
|
||||
t.Fatalf("calm → happy = %s, want direct happy", got)
|
||||
}
|
||||
m.Set(Calm)
|
||||
if got := m.Move(Angry); got != Sad {
|
||||
t.Fatalf("calm → angry = %s, want the legal intermediate sad", got)
|
||||
}
|
||||
if got := m.Move(Angry); got != Angry {
|
||||
t.Fatalf("sad → angry = %s, want direct angry", got)
|
||||
}
|
||||
m.Set(Calm)
|
||||
if got := m.Move(Excited); got != Happy {
|
||||
t.Fatalf("calm → excited = %s, want the legal intermediate happy", got)
|
||||
}
|
||||
// A target with no legal path leaves the machine where it was.
|
||||
p, err := NewMachine(Params{
|
||||
InitialState: Calm,
|
||||
Transitions: map[EmotionState][]EmotionState{Calm: {Happy}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewMachine: %v", err)
|
||||
}
|
||||
if got := p.Move(Sad); got != Calm {
|
||||
t.Fatalf("unreachable move = %s, want calm unchanged", got)
|
||||
}
|
||||
if got := m.Move(""); got != m.Current() {
|
||||
t.Fatalf("empty target moved to %s, want unchanged", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMachineMoveHonorsCustomTable verifies a persona's own transition table wins:
|
||||
// a custom edge that skips the built-in intermediate is taken directly.
|
||||
func TestMachineMoveHonorsCustomTable(t *testing.T) {
|
||||
t.Parallel()
|
||||
m, err := NewMachine(Params{
|
||||
InitialState: Calm,
|
||||
Transitions: map[EmotionState][]EmotionState{
|
||||
Calm: {Excited},
|
||||
Excited: {Calm},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewMachine: %v", err)
|
||||
}
|
||||
if got := m.Move(Excited); got != Excited {
|
||||
t.Fatalf("custom calm → excited = %s, want direct excited", got)
|
||||
}
|
||||
}
|
||||
@@ -155,6 +155,63 @@ func (m *Machine) ensure(from EmotionState) 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.
|
||||
|
||||
@@ -132,3 +132,14 @@ func (p *Persona) NewAffection() (*emotion.Affection, error) {
|
||||
}
|
||||
return emotion.NewAffection(params.Initial, params.Min, params.Max)
|
||||
}
|
||||
|
||||
// AffectionStep returns the per-turn affection magnitude of this persona's
|
||||
// 好感度曲线 (design/01 §2) — the configured `affection.delta`, or the loader
|
||||
// default when the persona declares no curve. It is the magnitude applied once
|
||||
// per emotional turn; the sign comes from the turn's signal.
|
||||
func (p *Persona) AffectionStep() int {
|
||||
if p.Affection.Delta != 0 {
|
||||
return p.Affection.Delta
|
||||
}
|
||||
return defaultAffectionDelta
|
||||
}
|
||||
@@ -234,6 +234,16 @@ func (r *Registry) Emotions() *emotion.Manager {
|
||||
return r.emotions
|
||||
}
|
||||
|
||||
// AffectionStep returns the per-turn affection magnitude configured for the
|
||||
// persona under personaID (its 好感度曲线 delta, design/01 §2). It returns 0 for
|
||||
// an unknown persona, so a caller skips the adjustment instead of inventing one.
|
||||
func (r *Registry) AffectionStep(personaID string) int {
|
||||
if p, ok := r.Persona(personaID); ok {
|
||||
return p.AffectionStep()
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// CountPersonas returns the number of registered personas.
|
||||
func (r *Registry) CountPersonas() int {
|
||||
r.mu.RLock()
|
||||
|
||||
Reference in new issue
Block a user