Skip to main content

nil_server_database/model/
game.rs

1// Copyright (C) Call of Nil contributors
2// SPDX-License-Identifier: AGPL-3.0-only
3
4use crate::Database;
5use crate::error::{Error, Result};
6use crate::sql_types::duration::db_Duration;
7use crate::sql_types::game_id::GameId;
8use crate::sql_types::hashed_password::HashedPassword;
9use crate::sql_types::id::UserId;
10use crate::sql_types::version::db_Version;
11use crate::sql_types::zoned::db_Zoned;
12use diesel::prelude::*;
13use nil_core::world::World;
14use nil_crypto::password::Password;
15use nil_server_types::round::RoundDuration;
16use std::fmt;
17use tokio::task::spawn_blocking;
18
19#[derive(Identifiable, Queryable, Selectable, Clone)]
20#[diesel(table_name = crate::schema::game)]
21#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
22#[diesel(belongs_to(UserData, foreign_key = created_by))]
23pub struct Game {
24  pub id: GameId,
25  pub password: Option<HashedPassword>,
26  pub description: Option<String>,
27  pub round_duration: Option<db_Duration>,
28  pub created_by: UserId,
29  pub created_at: db_Zoned,
30  pub updated_at: db_Zoned,
31}
32
33impl From<GameWithBlob> for Game {
34  fn from(game: GameWithBlob) -> Self {
35    Self {
36      id: game.id,
37      password: game.password,
38      description: game.description,
39      round_duration: game.round_duration,
40      created_by: game.created_by,
41      created_at: game.created_at,
42      updated_at: game.updated_at,
43    }
44  }
45}
46
47impl fmt::Debug for Game {
48  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49    f.debug_struct("Game")
50      .field("id", &self.id.to_string())
51      .field("round_duration", &self.round_duration)
52      .field("created_by", &self.created_by)
53      .field("created_at", &self.created_at.to_string())
54      .field("updated_at", &self.updated_at.to_string())
55      .finish_non_exhaustive()
56  }
57}
58
59#[derive(Identifiable, Queryable, Selectable, Clone)]
60#[diesel(table_name = crate::schema::game)]
61#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
62#[diesel(belongs_to(UserData, foreign_key = created_by))]
63pub struct GameWithBlob {
64  pub id: GameId,
65  pub password: Option<HashedPassword>,
66  pub description: Option<String>,
67  pub round_duration: Option<db_Duration>,
68  pub server_version: db_Version,
69  pub created_by: UserId,
70  pub created_at: db_Zoned,
71  pub updated_at: db_Zoned,
72  pub world_blob: Vec<u8>,
73}
74
75impl GameWithBlob {
76  #[inline]
77  pub fn to_world(&self) -> Result<World> {
78    Ok(World::load(&self.world_blob)?)
79  }
80}
81
82impl fmt::Debug for GameWithBlob {
83  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84    f.debug_struct("GameWithBlob")
85      .field("id", &self.id.to_string())
86      .field("round_duration", &self.round_duration)
87      .field("created_by", &self.created_by)
88      .field("created_at", &self.created_at.to_string())
89      .field("updated_at", &self.updated_at.to_string())
90      .finish_non_exhaustive()
91  }
92}
93
94#[derive(Insertable, Clone)]
95#[diesel(table_name = crate::schema::game)]
96pub struct NewGame {
97  id: GameId,
98  password: Option<HashedPassword>,
99  description: Option<String>,
100  round_duration: Option<db_Duration>,
101  server_version: db_Version,
102  created_by: UserId,
103  created_at: db_Zoned,
104  updated_at: db_Zoned,
105  world_blob: Vec<u8>,
106}
107
108#[bon::bon]
109impl NewGame {
110  #[builder]
111  pub async fn new(
112    #[builder(start_fn, into)] id: GameId,
113    #[builder(start_fn)] blob: Vec<u8>,
114    #[builder(into)] password: Option<Password>,
115    #[builder(into)] mut description: Option<String>,
116    #[builder(into)] round_duration: Option<RoundDuration>,
117    #[builder(into)] server_version: db_Version,
118    created_by: UserId,
119  ) -> Result<Self> {
120    if let Some(description) = &mut description {
121      let chars = description.chars().count();
122      let excess = chars.saturating_sub(1000);
123      if excess > 0 {
124        for _ in 0..excess {
125          description.pop();
126        }
127      }
128    }
129
130    let now = db_Zoned::now();
131
132    Ok(Self {
133      id,
134      password: hash_password(password).await?,
135      description,
136      round_duration: round_duration.map(Into::into),
137      server_version,
138      created_by,
139      created_at: now.clone(),
140      updated_at: now,
141      world_blob: blob,
142    })
143  }
144
145  #[inline]
146  pub fn blob(&self) -> &[u8] {
147    &self.world_blob
148  }
149
150  #[inline]
151  pub async fn create(self, database: &Database) -> Result<usize> {
152    database.create_game(self).await
153  }
154}
155
156impl fmt::Debug for NewGame {
157  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
158    f.debug_struct("NewGame")
159      .field("id", &self.id.to_string())
160      .field("round_duration", &self.round_duration)
161      .field("server_version", &self.server_version)
162      .field("created_by", &self.created_by)
163      .field("created_at", &self.created_at.to_string())
164      .field("updated_at", &self.updated_at.to_string())
165      .finish_non_exhaustive()
166  }
167}
168
169async fn hash_password(password: Option<Password>) -> Result<Option<HashedPassword>> {
170  let Some(password) = password else { return Ok(None) };
171  let pass_len = password.trim().chars().count();
172
173  if pass_len == 0 {
174    return Ok(None);
175  } else if !(3..=50).contains(&pass_len) {
176    return Err(Error::InvalidPassword);
177  }
178
179  spawn_blocking(move || HashedPassword::new(&password))
180    .await?
181    .map(Some)
182    .map_err(Into::into)
183}