Files
ShowenV2/src/plugins/video/state_machine.rs
showen d4f0eb7eca fix: 触发器响应优化 — pending trigger 在当前step结束后立即触发
修复网页触发器点击后无响应的问题。原因是 defer_triggers 模式下,
pending trigger 必须等待整个 sequence 播完才触发,当 step 有
random_loop_range [2,15] 时用户可能要等几分钟。
现在改为当前 step 循环结束后立即检查并执行 pending trigger。

同时添加 systemd service 脚本。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:08:10 +08:00

623 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use crate::core::config::{
AnimationStep, NextStateEntry, StateConfig, StateMachineConfig, StateMode, StateTransition,
TriggerType,
};
use anyhow::Result;
use rand::Rng;
pub struct StateMachine {
pub config: StateMachineConfig,
pub current_state: String,
pub current_sequence_index: usize,
pub current_loop_remaining: i32,
pub pending_trigger_target: Option<String>,
}
impl StateMachine {
pub fn new(config: StateMachineConfig) -> Self {
Self {
current_state: config.initial_state.clone(),
config,
current_sequence_index: 0,
current_loop_remaining: 0,
pending_trigger_target: None,
}
}
pub fn start(&mut self) -> Result<()> {
self.current_state = self.config.initial_state.clone();
self.pending_trigger_target = None;
self.reset_state_progress()
}
pub fn current_video_id(&self) -> Option<String> {
self.current_state_config()
.and_then(|state| state.sequence.get(self.current_sequence_index))
.map(|step| step.video_id.clone())
}
pub fn current_state_config(&self) -> Option<&StateConfig> {
self.config.states.get(&self.current_state)
}
pub fn on_video_completed(&mut self) -> Result<bool> {
self.ensure_current_state_valid()?;
let old_state = self.current_state.clone();
if self.current_loop_remaining > 1 {
self.current_loop_remaining -= 1;
return Ok(false);
}
// 有待执行的触发器时,跳过 sequence 剩余 step立即切换状态
if self.pending_trigger_target.is_some() {
let target_state = self.pending_trigger_target.take().unwrap();
self.transition_to_state(&target_state)?;
return Ok(self.current_state != old_state);
}
let sequence_len = self
.current_state_config()
.ok_or_else(|| anyhow::anyhow!("当前状态不存在: {}", self.current_state))?
.sequence
.len();
if self.current_sequence_index + 1 < sequence_len {
self.current_sequence_index += 1;
self.current_loop_remaining = self.resolve_current_loop_count()?;
return Ok(false);
}
if let Some(target_state) = self.pending_trigger_target.take() {
self.transition_to_state(&target_state)?;
return Ok(self.current_state != old_state);
}
let next_state = self.select_next_state()?;
self.transition_to_state(&next_state)?;
Ok(self.current_state != old_state)
}
pub fn handle_trigger(&mut self, name: &str, value: &str) -> Result<bool> {
let Some(state) = self.current_state_config() else {
return Ok(false);
};
if state.ignore_triggers {
return Ok(false);
}
if self.pending_trigger_target.is_some() {
return Ok(false);
}
let target_state = self
.matching_transition(&state.transitions, name, value)
.map(|transition| transition.target_state.clone());
let Some(target_state) = target_state else {
return Ok(false);
};
if state.defer_triggers {
self.pending_trigger_target = Some(target_state);
if self.current_loop_remaining > 1 {
self.current_loop_remaining = 1;
}
return Ok(false);
}
self.transition_to_state(&target_state)?;
Ok(true)
}
pub fn check_random_triggers(&mut self) -> Result<bool> {
let Some(state) = self.current_state_config() else {
return Ok(false);
};
if state.ignore_triggers {
return Ok(false);
}
if !matches!(state.mode, StateMode::FreeMode | StateMode::InteractiveMode) {
return Ok(false);
}
let mut rng = rand::thread_rng();
let target_state = state
.transitions
.iter()
.filter(|transition| match &transition.trigger {
TriggerType::Random { probability } => {
let probability = probability.clamp(0.0, 1.0);
rng.gen_bool(probability)
}
_ => false,
})
.max_by_key(|transition| transition.priority)
.map(|transition| transition.target_state.clone());
let Some(target_state) = target_state else {
return Ok(false);
};
if state.defer_triggers {
self.pending_trigger_target = Some(target_state);
return Ok(true);
}
self.transition_to_state(&target_state)?;
Ok(true)
}
pub fn has_pending_trigger(&self) -> bool {
self.pending_trigger_target.is_some()
}
fn reset_state_progress(&mut self) -> Result<()> {
self.current_sequence_index = 0;
self.ensure_current_state_valid()?;
self.current_loop_remaining = self.resolve_current_loop_count()?;
Ok(())
}
fn transition_to_state(&mut self, target_state: &str) -> Result<()> {
if !self.config.states.contains_key(target_state) {
anyhow::bail!("目标状态不存在: {}", target_state);
}
self.current_state = target_state.to_string();
self.pending_trigger_target = None;
self.reset_state_progress()
}
fn ensure_current_state_valid(&self) -> Result<()> {
let state = self
.current_state_config()
.ok_or_else(|| anyhow::anyhow!("当前状态不存在: {}", self.current_state))?;
if state.sequence.is_empty() {
anyhow::bail!("状态 '{}' 的 sequence 不能为空", state.name);
}
if self.current_sequence_index >= state.sequence.len() {
anyhow::bail!(
"状态 '{}' 的 sequence 索引越界: {} >= {}",
state.name,
self.current_sequence_index,
state.sequence.len()
);
}
Ok(())
}
fn resolve_current_loop_count(&self) -> Result<i32> {
if self.pending_trigger_target.is_some() {
return Ok(1);
}
let state = self
.current_state_config()
.ok_or_else(|| anyhow::anyhow!("当前状态不存在: {}", self.current_state))?;
let step = state
.sequence
.get(self.current_sequence_index)
.ok_or_else(|| {
anyhow::anyhow!(
"状态 '{}' 的 sequence 索引越界: {}",
state.name,
self.current_sequence_index
)
})?;
Ok(self.resolve_step_loop_count(step))
}
fn resolve_step_loop_count(&self, step: &AnimationStep) -> i32 {
if let Some([start, end]) = step.random_loop_range {
let min = start.min(end);
let max = start.max(end);
return rand::thread_rng().gen_range(min..=max).max(1);
}
step.loop_count.unwrap_or(1).max(1)
}
fn select_next_state(&self) -> Result<String> {
let state = self
.current_state_config()
.ok_or_else(|| anyhow::anyhow!("当前状态不存在: {}", self.current_state))?;
if let Some(entries) = &state.next_states {
if let Some(next_state) = self.select_weighted_next_state(entries) {
return Ok(next_state);
}
}
if let Some(next_state) = &state.next_state {
return Ok(next_state.clone());
}
// FreeMode 状态没有配置 next_state/next_states 时,按权重随机选择一个 FreeMode 状态
match state.mode {
StateMode::FreeMode => self.select_random_free_state(),
StateMode::InteractiveMode => Ok(self.current_state.clone()),
}
}
/// 按权重随机选择一个 FreeMode 状态(旧版行为回补)
fn select_random_free_state(&self) -> Result<String> {
let free_states: Vec<(&String, &StateConfig)> = self
.config
.states
.iter()
.filter(|(_, state)| matches!(state.mode, StateMode::FreeMode))
.collect();
if free_states.is_empty() {
anyhow::bail!("没有可用的 FreeMode 状态");
}
let total_weight: f32 = free_states.iter().map(|(_, state)| state.weight).sum();
if total_weight <= 0.0 {
return Ok(free_states[0].0.clone());
}
let mut cursor = rand::thread_rng().gen_range(0.0..total_weight);
for (name, state) in &free_states {
cursor -= state.weight;
if cursor <= 0.0 {
return Ok((*name).clone());
}
}
Ok(free_states.last().unwrap().0.clone())
}
fn select_weighted_next_state(&self, entries: &[NextStateEntry]) -> Option<String> {
let valid_entries: Vec<&NextStateEntry> =
entries.iter().filter(|entry| entry.weight > 0.0).collect();
if valid_entries.is_empty() {
return None;
}
let total_weight: f32 = valid_entries.iter().map(|entry| entry.weight).sum();
if total_weight <= 0.0 {
return None;
}
let mut cursor = rand::thread_rng().gen_range(0.0..total_weight);
for entry in &valid_entries {
cursor -= entry.weight;
if cursor <= 0.0 {
return Some(entry.state.clone());
}
}
valid_entries.last().map(|entry| entry.state.clone())
}
fn matching_transition<'a>(
&self,
transitions: &'a [StateTransition],
name: &str,
value: &str,
) -> Option<&'a StateTransition> {
transitions
.iter()
.filter(|transition| Self::trigger_matches(&transition.trigger, name, value))
.max_by_key(|transition| transition.priority)
}
fn trigger_matches(trigger: &TriggerType, name: &str, value: &str) -> bool {
match trigger {
TriggerType::Button { name: expected } => {
(name == "button" && value == expected) || expected == name
}
TriggerType::Voice { keyword } => keyword == value || keyword == name,
TriggerType::Sensor { name: expected } => {
(name == "sensor" && value == expected) || expected == name
}
TriggerType::Timer { .. } | TriggerType::Random { .. } => false,
}
}
}
#[cfg(test)]
mod tests {
use super::StateMachine;
use crate::core::config::{
AnimationStep, NextStateEntry, StateConfig, StateMachineConfig, StateMode, StateTransition,
TriggerType,
};
use std::collections::HashMap;
#[test]
fn button_and_sensor_triggers_match_legacy_message_shape() {
let mut machine = StateMachine::new(config_with_states([
state(
"idle",
vec![step("idle")],
vec![
transition(
TriggerType::Button {
name: "button1".to_string(),
},
"button_state",
10,
),
transition(
TriggerType::Sensor {
name: "touch".to_string(),
},
"sensor_state",
10,
),
],
),
state("button_state", vec![step("button")], vec![]),
state("sensor_state", vec![step("sensor")], vec![]),
]));
machine.start().expect("state machine should start");
assert!(machine
.handle_trigger("button", "button1")
.expect("button trigger should work"));
assert_eq!(machine.current_state, "button_state");
machine.transition_to_state("idle").expect("reset to idle");
assert!(machine
.handle_trigger("sensor", "touch")
.expect("sensor trigger should work"));
assert_eq!(machine.current_state, "sensor_state");
}
#[test]
fn deferred_trigger_waits_for_sequence_end_and_forces_single_remaining_loops() {
let mut machine = StateMachine::new(config_with_states([
StateConfig {
name: "idle".to_string(),
mode: StateMode::FreeMode,
sequence: vec![
AnimationStep {
video_id: "looping".to_string(),
loop_count: Some(3),
random_loop_range: None,
},
AnimationStep {
video_id: "tail".to_string(),
loop_count: Some(5),
random_loop_range: None,
},
],
next_state: None,
next_states: None,
transitions: vec![transition(
TriggerType::Voice {
keyword: "name".to_string(),
},
"called",
10,
)],
weight: 1.0,
defer_triggers: true,
ignore_triggers: false,
},
state("called", vec![step("called")], vec![]),
]));
machine.start().expect("state machine should start");
assert_eq!(machine.current_loop_remaining, 3);
assert!(!machine
.handle_trigger("voice", "name")
.expect("deferred trigger should queue"));
assert!(machine.has_pending_trigger());
assert_eq!(machine.current_state, "idle");
assert_eq!(machine.current_loop_remaining, 1);
// 当前 step 播完后,有 pending trigger 应立即跳转,跳过后续 step
assert!(machine
.on_video_completed()
.expect("pending trigger should fire after current step ends"));
assert_eq!(machine.current_state, "called");
assert_eq!(machine.current_video_id().as_deref(), Some("called"));
assert!(!machine.has_pending_trigger());
}
#[test]
fn on_video_completed_returns_false_when_staying_in_same_state() {
let mut machine = StateMachine::new(config_with_states([StateConfig {
name: "idle".to_string(),
mode: StateMode::FreeMode,
sequence: vec![step("a"), step("b")],
next_state: Some("idle".to_string()),
next_states: None,
transitions: vec![],
weight: 1.0,
defer_triggers: false,
ignore_triggers: false,
}]));
machine.start().expect("state machine should start");
assert!(!machine
.on_video_completed()
.expect("step transition should stay in same state"));
assert_eq!(machine.current_video_id().as_deref(), Some("b"));
}
#[test]
fn next_states_take_priority_over_next_state() {
let mut machine = StateMachine::new(config_with_states([
StateConfig {
name: "idle".to_string(),
mode: StateMode::FreeMode,
sequence: vec![step("idle")],
next_state: Some("fallback".to_string()),
next_states: Some(vec![NextStateEntry {
state: "weighted".to_string(),
weight: 1.0,
}]),
transitions: vec![],
weight: 1.0,
defer_triggers: false,
ignore_triggers: false,
},
state("weighted", vec![step("weighted")], vec![]),
state("fallback", vec![step("fallback")], vec![]),
]));
machine.start().expect("state machine should start");
assert!(machine
.on_video_completed()
.expect("state should change via next_states"));
assert_eq!(machine.current_state, "weighted");
}
#[test]
fn free_mode_random_walk_when_no_next_state_configured() {
let mut machine = StateMachine::new(config_with_states([
StateConfig {
name: "idle".to_string(),
mode: StateMode::FreeMode,
sequence: vec![step("idle")],
next_state: None,
next_states: None,
transitions: vec![],
weight: 2.0,
defer_triggers: false,
ignore_triggers: false,
},
StateConfig {
name: "walk".to_string(),
mode: StateMode::FreeMode,
sequence: vec![step("walk")],
next_state: None,
next_states: None,
transitions: vec![],
weight: 3.0,
defer_triggers: false,
ignore_triggers: false,
},
StateConfig {
name: "interactive".to_string(),
mode: StateMode::InteractiveMode,
sequence: vec![step("interactive")],
next_state: None,
next_states: None,
transitions: vec![],
weight: 1.0,
defer_triggers: false,
ignore_triggers: false,
},
]));
machine.start().expect("state machine should start");
let mut visited_states = std::collections::HashSet::new();
for _ in 0..20 {
machine
.on_video_completed()
.expect("should transition to random free state");
visited_states.insert(machine.current_state.clone());
// 验证只会跳转到 FreeMode 状态
assert!(
machine.current_state == "idle" || machine.current_state == "walk",
"应该只跳转到 FreeMode 状态,但跳转到了: {}",
machine.current_state
);
}
// 验证随机性20 次跳转应该至少访问过两个状态
assert!(
visited_states.len() >= 2,
"随机游走应该访问多个状态,但只访问了: {:?}",
visited_states
);
}
#[test]
fn interactive_mode_stays_in_same_state_when_no_next_state_configured() {
let states = vec![StateConfig {
name: "interactive".to_string(),
mode: StateMode::InteractiveMode,
sequence: vec![step("interactive")],
next_state: None,
next_states: None,
transitions: vec![],
weight: 1.0,
defer_triggers: false,
ignore_triggers: false,
}];
let states_map = states
.into_iter()
.map(|state| {
let name = state.name.clone();
(name, state)
})
.collect::<HashMap<_, _>>();
let mut machine = StateMachine::new(StateMachineConfig {
initial_state: "interactive".to_string(),
states: states_map,
});
machine.start().expect("state machine should start");
assert!(!machine
.on_video_completed()
.expect("interactive mode should stay in same state"));
assert_eq!(machine.current_state, "interactive");
}
fn config_with_states<const N: usize>(states: [StateConfig; N]) -> StateMachineConfig {
let states = Vec::from(states)
.into_iter()
.map(|state| {
let name = state.name.clone();
(name, state)
})
.collect::<HashMap<_, _>>();
StateMachineConfig {
initial_state: "idle".to_string(),
states,
}
}
fn state(
name: &str,
sequence: Vec<AnimationStep>,
transitions: Vec<StateTransition>,
) -> StateConfig {
StateConfig {
name: name.to_string(),
mode: StateMode::FreeMode,
sequence,
next_state: None,
next_states: None,
transitions,
weight: 1.0,
defer_triggers: false,
ignore_triggers: false,
}
}
fn step(video_id: &str) -> AnimationStep {
AnimationStep {
video_id: video_id.to_string(),
loop_count: Some(1),
random_loop_range: None,
}
}
fn transition(trigger: TriggerType, target_state: &str, priority: i32) -> StateTransition {
StateTransition {
trigger,
target_state: target_state.to_string(),
priority,
}
}
}