1use edda_types::{
5 admin::{CombatExport, ExportedCombat, SentCommand},
6 combat::{CombatEvent, CombatState, LoggedEvent},
7 encounter::Encounter,
8};
9use sqlx::types::Json;
10use uuid::Uuid;
11
12use crate::{
13 encounters,
14 error::ApiError,
15 store::{self, Store, Tx},
16};
17
18impl Store {
19 pub async fn export_campaign(&self, campaign: Uuid) -> Result<CombatExport, ApiError> {
21 let ids: Vec<(Uuid,)> =
22 sqlx::query_as("SELECT id FROM combats WHERE campaign = $1 ORDER BY created_at, id")
23 .bind(campaign.to_string())
24 .fetch_all(self.pool())
25 .await?;
26 let mut combats = Vec::with_capacity(ids.len());
27 for (id,) in ids {
28 combats.push(self.export_combat(id).await?);
29 }
30 let rows: Vec<encounters::Row> = sqlx::query_as(
31 "SELECT id, campaign, plan, version, created_at, updated_at FROM encounters \
32 WHERE campaign = $1 ORDER BY created_at, id",
33 )
34 .bind(campaign.to_string())
35 .fetch_all(self.pool())
36 .await?;
37 let encounters = rows
38 .into_iter()
39 .map(encounters::from_row)
40 .collect::<Result<_, _>>()?;
41 Ok(CombatExport {
42 combats,
43 encounters,
44 })
45 }
46
47 async fn export_combat(&self, id: Uuid) -> Result<ExportedCombat, ApiError> {
48 let log = self.events(id, 0).await?.unwrap_or_default();
49 let commands: Vec<(Uuid, Option<String>)> = sqlx::query_as(
50 "SELECT command_id, actor_key FROM combat_commands WHERE combat_id = $1 \
51 ORDER BY command_id",
52 )
53 .bind(id)
54 .fetch_all(self.pool())
55 .await?;
56 let synced: Option<(i64,)> =
57 sqlx::query_as("SELECT synced_seq FROM combat_hp_sync WHERE combat_id = $1")
58 .bind(id)
59 .fetch_optional(self.pool())
60 .await?;
61 Ok(ExportedCombat {
62 id,
63 log,
64 commands: commands
65 .into_iter()
66 .map(|(id, sender)| SentCommand { id, sender })
67 .collect(),
68 hp_synced: synced.map(|(seq,)| store::to_seq(seq)).transpose()?,
69 })
70 }
71
72 pub async fn import_campaign(
77 &self,
78 campaign: Uuid,
79 export: &CombatExport,
80 ) -> Result<u64, ApiError> {
81 let name = campaign.to_string();
82 if export.encounters.iter().any(|e| e.campaign != campaign) {
83 return Err(ApiError::BadRequest(
84 "a plan of another campaign is in the export".into(),
85 ));
86 }
87 let mut tx = self.begin().await?;
88 let (kept,): (i64,) = sqlx::query_as(
89 "SELECT (SELECT COUNT(*) FROM combats WHERE campaign = $1) \
90 + (SELECT COUNT(*) FROM encounters WHERE campaign = $1)",
91 )
92 .bind(&name)
93 .fetch_one(&mut *tx)
94 .await?;
95 if kept > 0 {
96 return Err(ApiError::Conflict(format!(
97 "campaign {campaign} has {kept} combat(s) or plan(s) here already"
98 )));
99 }
100 for combat in &export.combats {
101 import_combat(&mut tx, &name, combat).await?;
102 }
103 for encounter in &export.encounters {
104 import_encounter(&mut tx, encounter).await?;
105 }
106 tx.commit().await?;
107 let count = export.combats.len() + export.encounters.len();
108 Ok(u64::try_from(count).unwrap_or(u64::MAX))
109 }
110}
111
112async fn import_combat(
113 tx: &mut Tx,
114 campaign: &str,
115 combat: &ExportedCombat,
116) -> Result<(), ApiError> {
117 let whole = |log: &[LoggedEvent]| log.iter().zip(1..).all(|(event, seq)| event.seq == seq);
118 let (Some(first), Some(last)) = (combat.log.first(), combat.log.last()) else {
119 return Err(ApiError::BadRequest(format!(
120 "combat {} has no log",
121 combat.id
122 )));
123 };
124 let created_here =
125 matches!(&first.event, CombatEvent::Created { campaign: c, .. } if c == campaign);
126 if !whole(&combat.log) || !created_here {
127 return Err(ApiError::BadRequest(format!(
128 "combat {}'s log isn't whole, from its creation in this campaign",
129 combat.id
130 )));
131 }
132 if combat.hp_synced.is_some_and(|seq| seq > last.seq) {
133 return Err(ApiError::BadRequest(format!(
134 "combat {}'s sheets can't be ahead of its log",
135 combat.id
136 )));
137 }
138 let state = CombatState::replay(combat.log.iter().map(|e| &e.event));
139 sqlx::query(
140 "INSERT INTO combats (id, campaign, name, status, round, seq, created_at, updated_at) \
141 VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
142 )
143 .bind(combat.id)
144 .bind(campaign)
145 .bind(&state.name)
146 .bind(store::status_str(state.status))
147 .bind(i32::try_from(state.round).map_err(|e| ApiError::BadRequest(e.to_string()))?)
148 .bind(store::from_seq(last.seq)?)
149 .bind(first.at)
150 .bind(last.at)
151 .execute(&mut **tx)
152 .await?;
153 for event in &combat.log {
154 store::insert_event(tx, combat.id, event).await?;
155 }
156 for command in &combat.commands {
157 sqlx::query(
158 "INSERT INTO combat_commands (combat_id, command_id, actor_key) VALUES ($1, $2, $3)",
159 )
160 .bind(combat.id)
161 .bind(command.id)
162 .bind(&command.sender)
163 .execute(&mut **tx)
164 .await?;
165 }
166 if let Some(seq) = combat.hp_synced {
167 sqlx::query("INSERT INTO combat_hp_sync (combat_id, synced_seq) VALUES ($1, $2)")
168 .bind(combat.id)
169 .bind(store::from_seq(seq)?)
170 .execute(&mut **tx)
171 .await?;
172 }
173 Ok(())
174}
175
176async fn import_encounter(tx: &mut Tx, encounter: &Encounter) -> Result<(), ApiError> {
177 sqlx::query(
178 "INSERT INTO encounters (id, campaign, name, plan, version, created_at, updated_at) \
179 VALUES ($1, $2, $3, $4, $5, $6, $7)",
180 )
181 .bind(encounter.id)
182 .bind(encounter.campaign.to_string())
183 .bind(&encounter.plan.name)
184 .bind(Json(&encounter.plan))
185 .bind(i64::try_from(encounter.version).map_err(|e| ApiError::BadRequest(e.to_string()))?)
186 .bind(encounter.created_at)
187 .bind(encounter.updated_at)
188 .execute(&mut **tx)
189 .await?;
190 Ok(())
191}