Skip to main content

combat_service/
export.rs

1//! A campaign's combats, logs and all, and its planned encounters,
2//! exported to keep or to move to another server, and imported there.
3
4use 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    /// The campaign's combats and plans, as they are.
20    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    /// Takes in a campaign's combats and plans exported elsewhere, as they
73    /// were; returns how many came. All or nothing, and only while it has
74    /// none here. Each combat's log must be whole, from its creation in
75    /// this campaign on; the rest follows from it.
76    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}