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.query_row([fighter_name], |row| row.get(0)).ok();
55
56        match result {
57            Some(data) => {
58                let creed: Creed = serde_json::from_str(&data)
59                    .map_err(|e| PunchError::Memory(format!("failed to deserialize creed: {e}")))?;
60                Ok(Some(creed))
61            }
62            None => Ok(None),
63        }
64    }
65
66    /// Load a creed by fighter ID. Returns None if no creed exists.
67    pub async fn load_creed_by_fighter(
68        &self,
69        fighter_id: &FighterId,
70    ) -> PunchResult<Option<Creed>> {
71        let fighter_str = fighter_id.to_string();
72        let conn = self.conn.lock().await;
73        let mut stmt = conn
74            .prepare("SELECT creed_data FROM creeds WHERE fighter_id = ?1")
75            .map_err(|e| PunchError::Memory(format!("failed to prepare creed query: {e}")))?;
76
77        let result: Option<String> = stmt.query_row([&fighter_str], |row| row.get(0)).ok();
78
79        match result {
80            Some(data) => {
81                let creed: Creed = serde_json::from_str(&data)
82                    .map_err(|e| PunchError::Memory(format!("failed to deserialize creed: {e}")))?;
83                Ok(Some(creed))
84            }
85            None => Ok(None),
86        }
87    }
88
89    /// List all creeds.
90    pub async fn list_creeds(&self) -> PunchResult<Vec<Creed>> {
91        let conn = self.conn.lock().await;
92        let mut stmt = conn
93            .prepare("SELECT creed_data FROM creeds ORDER BY updated_at DESC")
94            .map_err(|e| PunchError::Memory(format!("failed to list creeds: {e}")))?;
95
96        let rows = stmt
97            .query_map([], |row| {
98                let data: String = row.get(0)?;
99                Ok(data)
100            })
101            .map_err(|e| PunchError::Memory(format!("failed to read creed rows: {e}")))?;
102
103        let mut creeds = Vec::new();
104        for row in rows {
105            let data = row.map_err(|e| PunchError::Memory(format!("failed to read creed: {e}")))?;
106            let creed: Creed = serde_json::from_str(&data)
107                .map_err(|e| PunchError::Memory(format!("failed to deserialize creed: {e}")))?;
108            creeds.push(creed);
109        }
110        Ok(creeds)
111    }
112
113    /// Delete a creed by fighter name.
114    pub async fn delete_creed(&self, fighter_name: &str) -> PunchResult<()> {
115        let conn = self.conn.lock().await;
116        conn.execute("DELETE FROM creeds WHERE fighter_name = ?1", [fighter_name])
117            .map_err(|e| PunchError::Memory(format!("failed to delete creed: {e}")))?;
118        debug!(fighter_name = fighter_name, "creed deleted");
119        Ok(())
120    }
121
122    /// Bind a creed to a specific fighter instance (after spawn/respawn).
123    pub async fn bind_creed_to_fighter(
124        &self,
125        fighter_name: &str,
126        fighter_id: &FighterId,
127    ) -> 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
195            .load_creed_by_name("charlie")
196            .await
197            .unwrap()
198            .unwrap();
199        assert_eq!(loaded.identity, "evolved");
200        assert_eq!(loaded.version, 2);
201        assert_eq!(loaded.bout_count, 5);
202    }
203
204    #[tokio::test]
205    async fn test_list_creeds() {
206        let substrate = MemorySubstrate::in_memory().unwrap();
207        substrate.save_creed(&make_creed("delta")).await.unwrap();
208        substrate.save_creed(&make_creed("echo")).await.unwrap();
209        substrate.save_creed(&make_creed("foxtrot")).await.unwrap();
210
211        let creeds = substrate.list_creeds().await.unwrap();
212        assert_eq!(creeds.len(), 3);
213
214        let names: Vec<&str> = creeds.iter().map(|c| c.fighter_name.as_str()).collect();
215        assert!(names.contains(&"delta"));
216        assert!(names.contains(&"echo"));
217        assert!(names.contains(&"foxtrot"));
218    }
219
220    #[tokio::test]
221    async fn test_delete_creed() {
222        let substrate = MemorySubstrate::in_memory().unwrap();
223        substrate.save_creed(&make_creed("golf")).await.unwrap();
224
225        // Verify it exists.
226        assert!(
227            substrate
228                .load_creed_by_name("golf")
229                .await
230                .unwrap()
231                .is_some()
232        );
233
234        // Delete it.
235        substrate.delete_creed("golf").await.unwrap();
236        assert!(
237            substrate
238                .load_creed_by_name("golf")
239                .await
240                .unwrap()
241                .is_none()
242        );
243    }
244
245    #[tokio::test]
246    async fn test_bind_creed_to_fighter() {
247        let substrate = MemorySubstrate::in_memory().unwrap();
248        let creed = make_creed("hotel");
249        substrate.save_creed(&creed).await.unwrap();
250
251        // Initially no fighter_id.
252        let loaded = substrate
253            .load_creed_by_name("hotel")
254            .await
255            .unwrap()
256            .unwrap();
257        assert!(loaded.fighter_id.is_none());
258
259        // Bind to a fighter.
260        let fighter_id = FighterId::new();
261        substrate
262            .bind_creed_to_fighter("hotel", &fighter_id)
263            .await
264            .unwrap();
265
266        // The creed_data JSON is not updated by bind, only the fighter_id column.
267        // So load_creed_by_fighter should find it via the index column.
268        let loaded = substrate.load_creed_by_fighter(&fighter_id).await.unwrap();
269        assert!(loaded.is_some());
270        assert_eq!(loaded.unwrap().fighter_name, "hotel");
271    }
272
273    #[tokio::test]
274    async fn test_load_nonexistent_creed_returns_none() {
275        let substrate = MemorySubstrate::in_memory().unwrap();
276        let by_name = substrate.load_creed_by_name("nonexistent").await.unwrap();
277        assert!(by_name.is_none());
278
279        let by_id = substrate
280            .load_creed_by_fighter(&FighterId::new())
281            .await
282            .unwrap();
283        assert!(by_id.is_none());
284    }
285}