Skip to main content

combat_service/
encounters.rs

1//! Planned encounters: CRUD over each campaign's reusable plans. Plans
2//! (and their notes) are the DM's, so player devices can't see them.
3
4use axum::{
5    Json, Router,
6    extract::{Path, State},
7    http::{StatusCode, header},
8    response::IntoResponse,
9    routing::get,
10};
11use chrono::{DateTime, Utc};
12use edda_auth::{protocol::Claims, server::Authenticated};
13use edda_types::encounter::{Encounter, EncounterPlan};
14use sqlx::{PgPool, types::Json as Jsonb};
15use uuid::Uuid;
16
17use edda_server::openapi::ErrorBody;
18
19use crate::{error::ApiError, hub::Hub, store};
20
21pub(crate) type Row = (
22    Uuid,
23    String,
24    Jsonb<EncounterPlan>,
25    i64,
26    DateTime<Utc>,
27    DateTime<Utc>,
28);
29
30/// The `/encounters` routes.
31pub fn router() -> Router<std::sync::Arc<Hub>> {
32    Router::new()
33        .route("/encounters", get(list).post(create))
34        .route("/encounters/{id}", get(fetch).put(replace).delete(remove))
35}
36
37/// Plans are the DM's: a player's device mustn't learn what's coming.
38pub fn dm_only(claims: &Claims) -> Result<(), ApiError> {
39    if claims.is_dm() {
40        Ok(())
41    } else {
42        Err(ApiError::Forbidden(
43            "only a DM device can see planned encounters".into(),
44        ))
45    }
46}
47
48/// One of `campaign`'s plans, to start a fight from; a bad request if
49/// there's no such plan.
50pub async fn plan(hub: &Hub, campaign: Uuid, id: Uuid) -> Result<EncounterPlan, ApiError> {
51    match get_one(hub, campaign, id).await {
52        Err(ApiError::NotFound) => Err(ApiError::BadRequest(
53            "there's no such planned encounter".into(),
54        )),
55        other => other.map(|e| e.plan),
56    }
57}
58
59fn check(plan: &EncounterPlan) -> Result<(), ApiError> {
60    if plan.name.trim().is_empty() {
61        return Err(ApiError::BadRequest("name must not be empty".into()));
62    }
63    if plan.monsters.iter().any(|m| m.count == 0) {
64        return Err(ApiError::BadRequest(
65            "every monster needs a count of at least 1".into(),
66        ));
67    }
68    Ok(())
69}
70
71pub(crate) fn from_row(
72    (id, campaign, Jsonb(plan), version, created_at, updated_at): Row,
73) -> Result<Encounter, ApiError> {
74    Ok(Encounter {
75        id,
76        campaign: campaign
77            .parse()
78            .map_err(|e: uuid::Error| ApiError::Corrupt(e.to_string()))?,
79        plan,
80        version: u64::try_from(version).map_err(|e| ApiError::Corrupt(e.to_string()))?,
81        created_at,
82        updated_at,
83    })
84}
85
86fn pool(hub: &Hub) -> &PgPool {
87    hub.store().pool()
88}
89
90#[utoipa::path(
91    get,
92    path = "/encounters",
93    tag = "encounters",
94    responses(
95        (status = 200, description = "The campaign's plans.", body = Vec<Encounter>),
96        (status = 403, description = "Not a DM device.", body = ErrorBody),
97    )
98)]
99/// The campaign's plans, by name.
100async fn list(
101    State(hub): State<std::sync::Arc<Hub>>,
102    Authenticated(claims): Authenticated,
103) -> Result<Json<Vec<Encounter>>, ApiError> {
104    dm_only(&claims)?;
105    let rows: Vec<Row> = sqlx::query_as(
106        "SELECT id, campaign, plan, version, created_at, updated_at FROM encounters \
107         WHERE campaign = $1 ORDER BY lower(name)",
108    )
109    .bind(claims.campaign.to_string())
110    .fetch_all(pool(&hub))
111    .await?;
112    Ok(Json(
113        rows.into_iter().map(from_row).collect::<Result<_, _>>()?,
114    ))
115}
116
117async fn get_one(hub: &Hub, campaign: Uuid, id: Uuid) -> Result<Encounter, ApiError> {
118    let row: Option<Row> = sqlx::query_as(
119        "SELECT id, campaign, plan, version, created_at, updated_at FROM encounters \
120         WHERE id = $1 AND campaign = $2",
121    )
122    .bind(id)
123    .bind(campaign.to_string())
124    .fetch_optional(pool(hub))
125    .await?;
126    row.map(from_row).transpose()?.ok_or(ApiError::NotFound)
127}
128
129#[utoipa::path(
130    get,
131    path = "/encounters/{id}",
132    tag = "encounters",
133    params(("id" = Uuid, Path, description = "The plan's id.")),
134    responses((status = 200, description = "The plan.", body = Encounter), (status = 403, description = "Not a DM device.", body = ErrorBody), (status = 404, description = "No such plan in the campaign.", body = ErrorBody))
135)]
136async fn fetch(
137    State(hub): State<std::sync::Arc<Hub>>,
138    Authenticated(claims): Authenticated,
139    Path(id): Path<Uuid>,
140) -> Result<Json<Encounter>, ApiError> {
141    dm_only(&claims)?;
142    Ok(Json(get_one(&hub, claims.campaign, id).await?))
143}
144
145#[utoipa::path(
146    post,
147    path = "/encounters",
148    tag = "encounters",
149    request_body = EncounterPlan,
150    responses(
151        (status = 201, description = "Kept.", body = Encounter, headers(("Location" = String, description = "Where it is: `/encounters/{id}`."))),
152        (status = 403, description = "Not a DM device, or the campaign has as many plans as it may keep.", body = ErrorBody),
153        (status = 422, description = "Not a valid plan.", body = ErrorBody),
154    )
155)]
156async fn create(
157    State(hub): State<std::sync::Arc<Hub>>,
158    Authenticated(claims): Authenticated,
159    Json(plan): Json<EncounterPlan>,
160) -> Result<impl IntoResponse, ApiError> {
161    dm_only(&claims)?;
162    check(&plan)?;
163    let (kept,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM encounters WHERE campaign = $1")
164        .bind(claims.campaign.to_string())
165        .fetch_one(pool(&hub))
166        .await?;
167    let limits = hub.limits().with(&claims.settings.quotas);
168    limits
169        .room_for(kept, 1, limits.plans, "planned encounters")
170        .map_err(ApiError::Forbidden)?;
171    let now = store::now();
172    let encounter = Encounter {
173        id: Uuid::new_v4(),
174        campaign: claims.campaign,
175        plan,
176        version: 1,
177        created_at: now,
178        updated_at: now,
179    };
180    sqlx::query(
181        "INSERT INTO encounters (id, campaign, name, plan, version, created_at, updated_at) \
182         VALUES ($1, $2, $3, $4, 1, $5, $5)",
183    )
184    .bind(encounter.id)
185    .bind(claims.campaign.to_string())
186    .bind(&encounter.plan.name)
187    .bind(Jsonb(&encounter.plan))
188    .bind(now)
189    .execute(pool(&hub))
190    .await?;
191    let location = format!("/encounters/{}", encounter.id);
192    Ok((
193        StatusCode::CREATED,
194        [(header::LOCATION, location)],
195        Json(encounter),
196    ))
197}
198
199#[utoipa::path(
200    put,
201    path = "/encounters/{id}",
202    tag = "encounters",
203    params(("id" = Uuid, Path, description = "The plan's id.")),
204    request_body = EncounterPlan,
205    responses(
206        (status = 200, description = "Replaced.", body = Encounter),
207        (status = 403, description = "Not a DM device.", body = ErrorBody),
208        (status = 404, description = "No such plan in the campaign.", body = ErrorBody),
209        (status = 422, description = "Not a valid plan.", body = ErrorBody),
210    )
211)]
212/// Replaces a plan; the last save wins.
213async fn replace(
214    State(hub): State<std::sync::Arc<Hub>>,
215    Authenticated(claims): Authenticated,
216    Path(id): Path<Uuid>,
217    Json(plan): Json<EncounterPlan>,
218) -> Result<Json<Encounter>, ApiError> {
219    dm_only(&claims)?;
220    check(&plan)?;
221    let row: Option<Row> = sqlx::query_as(
222        "UPDATE encounters SET name = $1, plan = $2, version = version + 1, updated_at = $3 \
223         WHERE id = $4 AND campaign = $5 \
224         RETURNING id, campaign, plan, version, created_at, updated_at",
225    )
226    .bind(&plan.name)
227    .bind(Jsonb(&plan))
228    .bind(store::now())
229    .bind(id)
230    .bind(claims.campaign.to_string())
231    .fetch_optional(pool(&hub))
232    .await?;
233    Ok(Json(
234        row.map(from_row).transpose()?.ok_or(ApiError::NotFound)?,
235    ))
236}
237
238#[utoipa::path(
239    delete,
240    path = "/encounters/{id}",
241    tag = "encounters",
242    params(("id" = Uuid, Path, description = "The plan's id.")),
243    responses((status = 204, description = "Deleted."), (status = 403, description = "Not a DM device.", body = ErrorBody), (status = 404, description = "No such plan in the campaign.", body = ErrorBody))
244)]
245async fn remove(
246    State(hub): State<std::sync::Arc<Hub>>,
247    Authenticated(claims): Authenticated,
248    Path(id): Path<Uuid>,
249) -> Result<StatusCode, ApiError> {
250    dm_only(&claims)?;
251    let result = sqlx::query("DELETE FROM encounters WHERE id = $1 AND campaign = $2")
252        .bind(id)
253        .bind(claims.campaign.to_string())
254        .execute(pool(&hub))
255        .await?;
256    if result.rows_affected() == 0 {
257        return Err(ApiError::NotFound);
258    }
259    Ok(StatusCode::NO_CONTENT)
260}