Skip to main content

punch_memory/
creed.rs

1//! Creed persistence — stores and loads fighter identity documents.
2//!
3//! Creeds are stored as JSON in the `creeds` table, keyed by fighter_name
4//! so they survive fighter kill/respawn cycles.
5
6use punch_types::{Creed, FighterId, PunchError, PunchResult};
7use tracing::debug;
8
9use crate::MemorySubstrate;
10
11impl MemorySubstrate {
12    /// Save or update a creed. Uses fighter_name as the natural key.
13    pub async fn save_creed(&self, creed: &Creed) -> PunchResult<()> {
14        let creed_data = serde_json::to_string(creed)
15            .map_err(|e| PunchError::Memory(format!("failed to serialize creed: {e}")))?;
16        let fighter_id_str = creed.fighter_id.map(|id| id.to_string());
17        let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
18
19        let conn = self.conn.lock().await;
20        conn.execute(
21            "INSERT INTO creeds (id, fighter_name, fighter_id, creed_data, version, bout_count, message_count, created_at, updated_at)
22             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?8)
23             ON CONFLICT(fighter_name) DO UPDATE SET
24                fighter_id = excluded.fighter_id,
25                creed_data = excluded.creed_data,
26                version = excluded.version,
27                bout_count = excluded.bout_count,
28                message_count = excluded.message_count,
29                updated_at = excluded.updated_at",
30            rusqlite::params![
31                creed.id.to_string(),
32                creed.fighter_name,
33                fighter_id_str,
34                creed_data,
35                creed.version,
36                creed.bout_count,
37                creed.message_count,
38                now,
39            ],
40        )
41        .map_err(|e| PunchError::Memory(format!("failed to save creed: {e}")))?;
42
43        debug!(fighter_name = %creed.fighter_name, version = creed.version, "creed saved");
44        Ok(())
45    }
46
47    /// Load a creed by fighter name. Returns None if no creed exists.
48    pub async fn load_creed_by_name(&self, fighter_name: &str) -> PunchResult<Option<Creed>> {
49        let conn = self.conn.lock().await;
50        let mut stmt = conn
51            .prepare("SELECT creed_data FROM creeds WHERE fighter_name = ?1")
52            .map_err(|e| PunchError::Memory(format!("failed to prepare creed query: {e}")))?;
53
54        let result: Option<String> = stmt
55            .query_row([fighter_name], |row| row.get(0))
56            .ok();
57
58        match result {
59            Some(data) => {
60                let creed: Creed = serde_json::from_str(&data)
61                    .map_err(|e| PunchError::Memory(format!("failed to deserialize creed: {e}")))?;
62                Ok(Some(creed))
63            }
64            None => Ok(None),
65        }
66    }
67
68    /// Load a creed by fighter ID. Returns None if no creed exists.
69    pub async fn load_creed_by_fighter(&self, fighter_id: &FighterId) -> PunchResult<Option<Creed>> {
70        let fighter_str = fighter_id.to_string();
71        let conn = self.conn.lock().await;
72        let mut stmt = conn
73            .prepare("SELECT creed_data FROM creeds WHERE fighter_id = ?1")
74            .map_err(|e| PunchError::Memory(format!("failed to prepare creed query: {e}")))?;
75
76        let result: Option<String> = stmt
77            .query_row([&fighter_str], |row| row.get(0))
78            .ok();
79
80        match result {
81            Some(data) => {
82                let creed: Creed = serde_json::from_str(&data)
83                    .map_err(|e| PunchError::Memory(format!("failed to deserialize creed: {e}")))?;
84                Ok(Some(creed))
85            }
86            None => Ok(None),
87        }
88    }
89
90    /// List all creeds.
91    pub async fn list_creeds(&self) -> PunchResult<Vec<Creed>> {
92        let conn = self.conn.lock().await;
93        let mut stmt = conn
94            .prepare("SELECT creed_data FROM creeds ORDER BY updated_at DESC")
95            .map_err(|e| PunchError::Memory(format!("failed to list creeds: {e}")))?;
96
97        let rows = stmt
98            .query_map([], |row| {
99                let data: String = row.get(0)?;
100                Ok(data)
101            })
102            .map_err(|e| PunchError::Memory(format!("failed to read creed rows: {e}")))?;
103
104        let mut creeds = Vec::new();
105        for row in rows {
106            let data = row.map_err(|e| PunchError::Memory(format!("failed to read creed: {e}")))?;
107            let creed: Creed = serde_json::from_str(&data)
108                .map_err(|e| PunchError::Memory(format!("failed to deserialize creed: {e}")))?;
109            creeds.push(creed);
110        }
111        Ok(creeds)
112    }
113
114    /// Delete a creed by fighter name.
115    pub async fn delete_creed(&self, fighter_name: &str) -> PunchResult<()> {
116        let conn = self.conn.lock().await;
117        conn.execute(
118            "DELETE FROM creeds WHERE fighter_name = ?1",
119            [fighter_name],
120        )
121        .map_err(|e| PunchError::Memory(format!("failed to delete creed: {e}")))?;
122        debug!(fighter_name = fighter_name, "creed deleted");
123        Ok(())
124    }
125
126    /// Bind a creed to a specific fighter instance (after spawn/respawn).
127    pub async fn bind_creed_to_fighter(&self, fighter_name: &str, fighter_id: &FighterId) -> PunchResult<()> {
128        let fighter_str = fighter_id.to_string();
129        let conn = self.conn.lock().await;
130        conn.execute(
131            "UPDATE creeds SET fighter_id = ?1, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE fighter_name = ?2",
132            rusqlite::params![fighter_str, fighter_name],
133        )
134        .map_err(|e| PunchError::Memory(format!("failed to bind creed: {e}")))?;
135        debug!(fighter_name = fighter_name, fighter_id = %fighter_id, "creed bound to fighter");
136        Ok(())
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use punch_types::{Creed, FighterId};
143
144    use crate::MemorySubstrate;
145
146    fn make_creed(name: &str) -> Creed {
147        Creed::new(name)
148    }
149
150    #[tokio::test]
151    async fn test_save_and_load_creed_by_name() {
152        let substrate = MemorySubstrate::in_memory().unwrap();
153        let creed = make_creed("atlas");
154
155        substrate.save_creed(&creed).await.unwrap();
156        let loaded = substrate.load_creed_by_name("atlas").await.unwrap();
157        assert!(loaded.is_some());
158        let loaded = loaded.unwrap();
159        assert_eq!(loaded.fighter_name, "atlas");
160        assert_eq!(loaded.id, creed.id);
161        assert_eq!(loaded.version, 1);
162        assert_eq!(loaded.bout_count, 0);
163        assert_eq!(loaded.message_count, 0);
164    }
165
166    #[tokio::test]
167    async fn test_load_creed_by_fighter() {
168        let substrate = MemorySubstrate::in_memory().unwrap();
169        let fighter_id = FighterId::new();
170        let mut creed = make_creed("bravo");
171        creed.fighter_id = Some(fighter_id);
172
173        substrate.save_creed(&creed).await.unwrap();
174        let loaded = substrate.load_creed_by_fighter(&fighter_id).await.unwrap();
175        assert!(loaded.is_some());
176        let loaded = loaded.unwrap();
177        assert_eq!(loaded.fighter_name, "bravo");
178        assert_eq!(loaded.fighter_id, Some(fighter_id));
179    }
180
181    #[tokio::test]
182    async fn test_save_creed_upsert() {
183        let substrate = MemorySubstrate::in_memory().unwrap();
184        let mut creed = make_creed("charlie");
185        creed.identity = "original".into();
186        substrate.save_creed(&creed).await.unwrap();
187
188        // Update the creed and save again — should upsert by fighter_name.
189        creed.identity = "evolved".into();
190        creed.version = 2;
191        creed.bout_count = 5;
192        substrate.save_creed(&creed).await.unwrap();
193
194        let loaded = substrate.load_creed_by_name("charlie").await.unwrap().unwrap();
195        assert_eq!(loaded.identity, "evolved");
196        assert_eq!(loaded.version, 2);
197        assert_eq!(loaded.bout_count, 5);
198    }
199
200    #[tokio::test]
201    async fn test_list_creeds() {
202        let substrate = MemorySubstrate::in_memory().unwrap();
203        substrate.save_creed(&make_creed("delta")).await.unwrap();
204        substrate.save_creed(&make_creed("echo")).await.unwrap();
205        substrate.save_creed(&make_creed("foxtrot")).await.unwrap();
206
207        let creeds = substrate.list_creeds().await.unwrap();
208        assert_eq!(creeds.len(), 3);
209
210        let names: Vec<&str> = creeds.iter().map(|c| c.fighter_name.as_str()).collect();
211        assert!(names.contains(&"delta"));
212        assert!(names.contains(&"echo"));
213        assert!(names.contains(&"foxtrot"));
214    }
215
216    #[tokio::test]
217    async fn test_delete_creed() {
218        let substrate = MemorySubstrate::in_memory().unwrap();
219        substrate.save_creed(&make_creed("golf")).await.unwrap();
220
221        // Verify it exists.
222        assert!(substrate.load_creed_by_name("golf").await.unwrap().is_some());
223
224        // Delete it.
225        substrate.delete_creed("golf").await.unwrap();
226        assert!(substrate.load_creed_by_name("golf").await.unwrap().is_none());
227    }
228
229    #[tokio::test]
230    async fn test_bind_creed_to_fighter() {
231        let substrate = MemorySubstrate::in_memory().unwrap();
232        let creed = make_creed("hotel");
233        substrate.save_creed(&creed).await.unwrap();
234
235        // Initially no fighter_id.
236        let loaded = substrate.load_creed_by_name("hotel").await.unwrap().unwrap();
237        assert!(loaded.fighter_id.is_none());
238
239        // Bind to a fighter.
240        let fighter_id = FighterId::new();
241        substrate.bind_creed_to_fighter("hotel", &fighter_id).await.unwrap();
242
243        // The creed_data JSON is not updated by bind, only the fighter_id column.
244        // So load_creed_by_fighter should find it via the index column.
245        let loaded = substrate.load_creed_by_fighter(&fighter_id).await.unwrap();
246        assert!(loaded.is_some());
247        assert_eq!(loaded.unwrap().fighter_name, "hotel");
248    }
249
250    #[tokio::test]
251    async fn test_load_nonexistent_creed_returns_none() {
252        let substrate = MemorySubstrate::in_memory().unwrap();
253        let by_name = substrate.load_creed_by_name("nonexistent").await.unwrap();
254        assert!(by_name.is_none());
255
256        let by_id = substrate.load_creed_by_fighter(&FighterId::new()).await.unwrap();
257        assert!(by_id.is_none());
258    }
259}