修复网页触发器点击后无响应的问题。原因是 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>
623 lines
20 KiB
Rust
623 lines
20 KiB
Rust
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,
|
||
}
|
||
}
|
||
}
|