|
|
@@ -0,0 +1,203 @@
|
|
|
+use serde::Serialize;
|
|
|
+
|
|
|
+use crate::api::models::{CfcTask, DailyCheckin, EnergyOverview};
|
|
|
+
|
|
|
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
|
|
+#[serde(rename_all = "camelCase")]
|
|
|
+pub enum PetState {
|
|
|
+ Idle,
|
|
|
+ Happy,
|
|
|
+ Busy,
|
|
|
+ Worried,
|
|
|
+ Excited,
|
|
|
+ Remind,
|
|
|
+ Energetic,
|
|
|
+ Tired,
|
|
|
+ Curious,
|
|
|
+ Sad,
|
|
|
+ Sleeping,
|
|
|
+ Watching,
|
|
|
+ WaitingForInteraction,
|
|
|
+ MiniGame,
|
|
|
+}
|
|
|
+
|
|
|
+#[derive(Debug, Clone, Serialize)]
|
|
|
+#[serde(rename_all = "camelCase")]
|
|
|
+pub struct PetStatePayload {
|
|
|
+ pub state: PetState,
|
|
|
+ pub reason: Option<String>,
|
|
|
+ pub data: Option<serde_json::Value>,
|
|
|
+}
|
|
|
+
|
|
|
+/// 核心状态映射。优先级:Sleeping > Excited > Happy > Worried > Busy > Sad > Tired/Energetic/Curious > Idle
|
|
|
+pub fn map_state(
|
|
|
+ tasks: Option<&[CfcTask]>,
|
|
|
+ energy: Option<&EnergyOverview>,
|
|
|
+ checkin: Option<&DailyCheckin>,
|
|
|
+ user_inactive: bool,
|
|
|
+) -> PetState {
|
|
|
+ let has_pending = tasks
|
|
|
+ .map(|ts| {
|
|
|
+ ts.iter().any(|t| matches!(t.status.as_deref(), Some("PENDING") | Some("IN_PROGRESS")))
|
|
|
+ })
|
|
|
+ .unwrap_or(false);
|
|
|
+
|
|
|
+ if user_inactive && !has_pending {
|
|
|
+ return PetState::Sleeping;
|
|
|
+ }
|
|
|
+
|
|
|
+ if let Some(c) = checkin {
|
|
|
+ if c.checked {
|
|
|
+ return PetState::Excited;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if let Some(ts) = tasks {
|
|
|
+ if !ts.is_empty() {
|
|
|
+ if ts.iter().all(|t| t.status.as_deref() == Some("COMPLETED")) {
|
|
|
+ return PetState::Happy;
|
|
|
+ }
|
|
|
+ let today = chrono::Local::now().date_naive();
|
|
|
+ let overdue = ts.iter().any(|t| {
|
|
|
+ matches!(t.status.as_deref(), Some("PENDING") | Some("IN_PROGRESS"))
|
|
|
+ && t.due_date
|
|
|
+ .as_deref()
|
|
|
+ .and_then(|d| {
|
|
|
+ let head = &d[..d.len().min(10)];
|
|
|
+ chrono::NaiveDate::parse_from_str(head, "%Y-%m-%d").ok()
|
|
|
+ })
|
|
|
+ .map(|due| due < today)
|
|
|
+ .unwrap_or(false)
|
|
|
+ });
|
|
|
+ if overdue {
|
|
|
+ return PetState::Worried;
|
|
|
+ }
|
|
|
+ return PetState::Busy;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if let Some(e) = energy {
|
|
|
+ if !e.dimensions.is_empty() {
|
|
|
+ let all_low = e.dimensions.iter().all(|d| d.value < 30);
|
|
|
+ if all_low {
|
|
|
+ return PetState::Sad;
|
|
|
+ }
|
|
|
+ let body = e.dimensions.iter().find(|d| d.dimension.as_deref() == Some("身"));
|
|
|
+ if let Some(b) = body {
|
|
|
+ if b.value < 30 {
|
|
|
+ return PetState::Tired;
|
|
|
+ }
|
|
|
+ if b.value >= 85 {
|
|
|
+ return PetState::Energetic;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ let mind = e.dimensions.iter().find(|d| d.dimension.as_deref() == Some("智"));
|
|
|
+ if let Some(m) = mind {
|
|
|
+ if m.value >= 85 {
|
|
|
+ return PetState::Curious;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ PetState::Idle
|
|
|
+}
|
|
|
+
|
|
|
+#[cfg(test)]
|
|
|
+mod tests {
|
|
|
+ use super::*;
|
|
|
+ use crate::api::models::{CfcTask, DailyCheckin, DimensionValue, EnergyOverview};
|
|
|
+
|
|
|
+ fn task(status: &str, due: Option<&str>) -> CfcTask {
|
|
|
+ CfcTask {
|
|
|
+ id: 1,
|
|
|
+ title: None,
|
|
|
+ status: Some(status.to_string()),
|
|
|
+ due_date: due.map(|s| s.to_string()),
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ fn energy(dims: &[(&str, i32)]) -> EnergyOverview {
|
|
|
+ EnergyOverview {
|
|
|
+ dimensions: dims
|
|
|
+ .iter()
|
|
|
+ .map(|(d, v)| DimensionValue {
|
|
|
+ dimension: Some(d.to_string()),
|
|
|
+ value: *v,
|
|
|
+ color: None,
|
|
|
+ })
|
|
|
+ .collect(),
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ #[test]
|
|
|
+ fn sleeping_when_inactive_and_no_pending() {
|
|
|
+ assert_eq!(map_state(Some(&[]), None, None, true), PetState::Sleeping);
|
|
|
+ }
|
|
|
+
|
|
|
+ #[test]
|
|
|
+ fn not_sleeping_when_inactive_but_pending() {
|
|
|
+ let tasks = vec![task("PENDING", None)];
|
|
|
+ assert_eq!(map_state(Some(&tasks), None, None, true), PetState::Busy);
|
|
|
+ }
|
|
|
+
|
|
|
+ #[test]
|
|
|
+ fn excited_beats_tasks_when_checked() {
|
|
|
+ let tasks = vec![task("PENDING", None)];
|
|
|
+ let checkin = DailyCheckin { checked: true, items: vec!["water".into()] };
|
|
|
+ assert_eq!(map_state(Some(&tasks), None, Some(&checkin), false), PetState::Excited);
|
|
|
+ }
|
|
|
+
|
|
|
+ #[test]
|
|
|
+ fn happy_when_all_completed() {
|
|
|
+ let tasks = vec![task("COMPLETED", None), task("COMPLETED", Some("2026-01-01"))];
|
|
|
+ assert_eq!(map_state(Some(&tasks), None, None, false), PetState::Happy);
|
|
|
+ }
|
|
|
+
|
|
|
+ #[test]
|
|
|
+ fn worried_when_overdue() {
|
|
|
+ let tasks = vec![task("PENDING", Some("2000-01-01"))];
|
|
|
+ assert_eq!(map_state(Some(&tasks), None, None, false), PetState::Worried);
|
|
|
+ }
|
|
|
+
|
|
|
+ #[test]
|
|
|
+ fn busy_when_in_progress_not_overdue() {
|
|
|
+ let tasks = vec![task("IN_PROGRESS", Some("2099-12-31"))];
|
|
|
+ assert_eq!(map_state(Some(&tasks), None, None, false), PetState::Busy);
|
|
|
+ }
|
|
|
+
|
|
|
+ #[test]
|
|
|
+ fn sad_when_all_dimensions_low() {
|
|
|
+ let e = energy(&[("身", 10), ("智", 20), ("心", 15)]);
|
|
|
+ assert_eq!(map_state(None, Some(&e), None, false), PetState::Sad);
|
|
|
+ }
|
|
|
+
|
|
|
+ #[test]
|
|
|
+ fn tired_when_body_low() {
|
|
|
+ let e = energy(&[("身", 20), ("智", 90)]);
|
|
|
+ assert_eq!(map_state(None, Some(&e), None, false), PetState::Tired);
|
|
|
+ }
|
|
|
+
|
|
|
+ #[test]
|
|
|
+ fn energetic_when_body_high() {
|
|
|
+ let e = energy(&[("身", 90)]);
|
|
|
+ assert_eq!(map_state(None, Some(&e), None, false), PetState::Energetic);
|
|
|
+ }
|
|
|
+
|
|
|
+ #[test]
|
|
|
+ fn curious_when_mind_high() {
|
|
|
+ let e = energy(&[("身", 60), ("智", 88)]);
|
|
|
+ assert_eq!(map_state(None, Some(&e), None, false), PetState::Curious);
|
|
|
+ }
|
|
|
+
|
|
|
+ #[test]
|
|
|
+ fn idle_when_nothing_qualifies() {
|
|
|
+ assert_eq!(map_state(None, None, None, false), PetState::Idle);
|
|
|
+ }
|
|
|
+
|
|
|
+ #[test]
|
|
|
+ fn state_serializes_to_camel_case() {
|
|
|
+ let json = serde_json::to_string(&PetState::WaitingForInteraction).unwrap();
|
|
|
+ assert_eq!(json, "\"waitingForInteraction\"");
|
|
|
+ }
|
|
|
+}
|