diff --git a/.env.TEMPLATE b/.env.TEMPLATE deleted file mode 100644 index 3e1ad7b..0000000 --- a/.env.TEMPLATE +++ /dev/null @@ -1,6 +0,0 @@ -DISCORD_TOKEN= -RUST_LOG=warn,siren=info -POSTGRES_USER=siren -POSTGRES_PASSWORD= -POSTGRES_DB=siren -OPENAI_API_KEY= \ No newline at end of file diff --git a/.gitignore b/.gitignore index c6f6245..2a8cf44 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,7 @@ .env target/ .idea/ -.vscode/ **/Cargo.lock -audio/ logs/ -settings.json app/ -data/ diff --git a/.version b/.version deleted file mode 100644 index 4f1b91d..0000000 --- a/.version +++ /dev/null @@ -1 +0,0 @@ -SIREN_VERSION=0.2.3 \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..ffa8b01 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "rust-analyzer.linkedProjects": [ + "./service/Cargo.toml", + "./bot/Cargo.toml", + ] +} \ No newline at end of file diff --git a/Makefile b/Makefile deleted file mode 100644 index 1138d49..0000000 --- a/Makefile +++ /dev/null @@ -1,34 +0,0 @@ -#!make -SHELL := /bin/bash - -include .env -include .version -export $(shell sed 's/=.*//' .env) -export $(shell sed 's/=.*//' .version) - -SIREN_IMAGES = $(shell docker images 'siren' -a -q) - -.PHONY: help build test up down exec clean - -help: ## Help command - @echo - @cat Makefile | grep -E '^[a-zA-Z\/_-]+:.*?## .*$$' | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' - @echo - -build: ## Build the docker image - docker build -t siren:${SIREN_VERSION} . - -test: ## Run the docker app as a container - docker run --env-file .env -it --rm --name siren siren:${SIREN_VERSION} - -up: ## Start the app - docker compose up -d - -down: ## Stop the app - docker compose down - -exec: ## Enter running docker container - docker exec -it siren bash - -clean: ## Cleanup docker images - docker rmi $(SIREN_IMAGES) diff --git a/bot/.env.TEMPLATE b/bot/.env.TEMPLATE new file mode 100644 index 0000000..dd583f4 --- /dev/null +++ b/bot/.env.TEMPLATE @@ -0,0 +1,8 @@ +RUST_LOG=warn,bot=info +COMPOSE_PROJECT_NAME=siren + +SERVICE_HOST=localhost +SERVICE_PORT=5000 + +DISCORD_TOKEN= +OPENAI_API_KEY= \ No newline at end of file diff --git a/Cargo.toml b/bot/Cargo.toml similarity index 70% rename from Cargo.toml rename to bot/Cargo.toml index be626fc..d57de98 100644 --- a/Cargo.toml +++ b/bot/Cargo.toml @@ -1,6 +1,6 @@ [package] -name = "siren" -version = "0.2.3" +name = "bot" +version = "0.2.4" edition = "2021" authors = ["Ben Sherriff "] repository = "https://github.com/bensherriff/siren" @@ -8,10 +8,12 @@ readme = "README.md" license = "GPL-3.0-or-later" [dependencies] +chrono = { version = "0.4.31", features = ["serde"] } dotenv = "0.15.0" -serde_json = "1.0" -log = "0.4.19" +serde_json = "1.0.107" +log = "0.4.20" env_logger = "0.10.0" +service = { path = "../service" } [dependencies.serenity] version = "0.11.6" @@ -23,19 +25,18 @@ version = "0.3.2" features = ["builtin-queue", "yt-dlp"] [dependencies.tokio] -version = "1.29.1" +version = "1.32.0" features = ["macros", "rt-multi-thread"] [dependencies.serde] -version = "1.0" +version = "1.0.188" features = ["derive"] [dependencies.reqwest] -version = "0.11.18" +version = "0.11.22" default-features = false features = ["json", "rustls-tls"] -[dependencies.diesel] -version = "2.1.0" -default-features = false -features = ["postgres", "32-column-tables", "serde_json", "r2d2", "with-deprecated"] \ No newline at end of file +[dependencies.pyo3] +version = "0.19.2" +features = ["auto-initialize"] diff --git a/Dockerfile b/bot/Dockerfile similarity index 80% rename from Dockerfile rename to bot/Dockerfile index 0af2576..5811412 100644 --- a/Dockerfile +++ b/bot/Dockerfile @@ -1,12 +1,15 @@ -FROM rust:1.70.0 as builder -WORKDIR /siren -ADD src ./src/ -ADD Cargo.toml ./ -RUN apt-get update && apt-get install -y cmake && \ - cargo build --release --bin siren +# Builder +FROM rust:1.72.1-bookworm as builder +WORKDIR /builder +COPY src ./src +COPY Cargo.toml ./ +RUN cargo build --release + +# Packages FROM debian:bullseye-slim as packages WORKDIR /packages + RUN apt-get update && apt-get install -y curl tar xz-utils && \ curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux > yt-dlp && \ chmod +x yt-dlp && \ @@ -19,10 +22,11 @@ RUN apt-get update && apt-get install -y curl tar xz-utils && \ # curl -L https://download.pytorch.org/libtorch/cu117/libtorch-cxx11-abi-shared-with-deps-2.0.1%2Bcu117.zip > libtorch.zip && \ # unzip libtorch.zip && rm libtorch.zip +# Runner FROM debian:bullseye-slim as runtime -WORKDIR /siren +WORKDIR /bot RUN apt-get update && apt-get install -y libopus-dev libpq5 libpq-dev && apt-get auto-remove -y -COPY --from=builder /siren/target/release/siren /usr/local/bin/siren +COPY --from=builder /builder/target/release/bot /usr/local/bin/bot COPY --from=packages /packages /usr/bin # COPY --from=libraries /libraries /usr/lib @@ -30,4 +34,4 @@ COPY --from=packages /packages /usr/bin # ARG LD_LIBRARY_PATH=${LIBTORCH}/lib:${LD_LIBRARY_PATH} # ADD migrations ./ -CMD ["siren"] +CMD ["bot"] diff --git a/bot/Makefile b/bot/Makefile new file mode 100644 index 0000000..0845f7c --- /dev/null +++ b/bot/Makefile @@ -0,0 +1,27 @@ +#!make +SHELL := /bin/bash + +include .env + +.PHONY: help build test up down exec clean + +help: ## Help command + @echo + @cat Makefile | grep -E '^[a-zA-Z\/_-]+:.*?## .*$$' | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' + @echo + +build: ## Build the docker image + docker compose build + +db: ## Start the docker database + docker compose up -d db + +up: ## Start the app + docker compose up -d + +down: ## Stop the app + docker compose down + +clean: + docker compose down && \ + docker image rm siren-bot diff --git a/bot/docker-compose.yml b/bot/docker-compose.yml new file mode 100644 index 0000000..67cde8b --- /dev/null +++ b/bot/docker-compose.yml @@ -0,0 +1,20 @@ +version: '3.8' + +services: + bot: + image: siren-bot:${BOT_VERSION:-latest} + container_name: siren-bot + build: + context: . + dockerfile: ./Dockerfile + args: + - VERSION=${BOT_VERSION:-latest} + env_file: + - .env + networks: + - frontend + restart: unless-stopped + + +networks: + frontend: diff --git a/src/commands/audio/mod.rs b/bot/src/commands/audio/mod.rs similarity index 100% rename from src/commands/audio/mod.rs rename to bot/src/commands/audio/mod.rs diff --git a/src/commands/audio/pause.rs b/bot/src/commands/audio/pause.rs similarity index 100% rename from src/commands/audio/pause.rs rename to bot/src/commands/audio/pause.rs diff --git a/src/commands/audio/play.rs b/bot/src/commands/audio/play.rs similarity index 100% rename from src/commands/audio/play.rs rename to bot/src/commands/audio/play.rs diff --git a/src/commands/audio/resume.rs b/bot/src/commands/audio/resume.rs similarity index 100% rename from src/commands/audio/resume.rs rename to bot/src/commands/audio/resume.rs diff --git a/src/commands/audio/skip.rs b/bot/src/commands/audio/skip.rs similarity index 100% rename from src/commands/audio/skip.rs rename to bot/src/commands/audio/skip.rs diff --git a/src/commands/audio/stop.rs b/bot/src/commands/audio/stop.rs similarity index 100% rename from src/commands/audio/stop.rs rename to bot/src/commands/audio/stop.rs diff --git a/src/commands/audio/volume.rs b/bot/src/commands/audio/volume.rs similarity index 100% rename from src/commands/audio/volume.rs rename to bot/src/commands/audio/volume.rs diff --git a/src/commands/help.rs b/bot/src/commands/help.rs similarity index 100% rename from src/commands/help.rs rename to bot/src/commands/help.rs diff --git a/src/commands/mod.rs b/bot/src/commands/mod.rs similarity index 100% rename from src/commands/mod.rs rename to bot/src/commands/mod.rs diff --git a/src/commands/oai.rs b/bot/src/commands/oai.rs similarity index 67% rename from src/commands/oai.rs rename to bot/src/commands/oai.rs index 1576be2..2727f58 100644 --- a/src/commands/oai.rs +++ b/bot/src/commands/oai.rs @@ -1,9 +1,3 @@ - -use std::error::Error; -use std::fmt; - -use diesel::{prelude::*, PgConnection, insert_into}; -use diesel::r2d2::{Pool, ConnectionManager}; use log::{error, debug, trace, warn}; use serde::{Serialize, Deserialize}; @@ -12,14 +6,16 @@ use serenity::model::Permissions; use serenity::model::channel::Message; use serenity::model::prelude::{ChannelType, PermissionOverwrite, PermissionOverwriteType}; use serenity::prelude::*; - -use crate::database::models::{NewMessageDB, MessageDB}; +use siren::{GetResponse, ServiceError}; pub struct OAI { pub client: reqwest::Client, pub base_url: String, + pub service_url: String, pub max_attempts: i64, pub token: String, + pub max_tokens: i64, + pub default_model: GPTModel, pub max_context_questions: i64 } @@ -66,7 +62,7 @@ enum GPTRole { } #[derive(Debug, Clone, Serialize, Deserialize)] -enum GPTModel { +pub enum GPTModel { #[serde(rename = "gpt-3.5-turbo")] GPT35Turbo, #[serde(rename = "gpt-3.5-turbo-0613")] @@ -111,26 +107,18 @@ struct Choice { #[derive(Debug, Clone, Serialize, Deserialize)] struct ResponseError { - code: Option, + error: Option, message: Option, param: Option, #[serde(rename = "type")] error_type: Option } -#[derive(Debug)] -struct OAIError { - pub message: String +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ErrorDetails { + code: Option } -impl fmt::Display for OAIError { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "OAIError: {}", self.message) - } -} - -impl Error for OAIError {} - #[derive(Debug, Clone, Serialize, Deserialize)] enum ResponseEvent { ChatCompletionResponse(ChatCompletionResponse), @@ -138,118 +126,117 @@ enum ResponseEvent { } impl OAI { - async fn get_request(&self, request: ChatCompletionRequest) -> Result { + async fn get_request(&self, request: ChatCompletionRequest) -> Result { let uri = format!("{}/chat/completions", self.base_url); let body = serde_json::to_string(&request).unwrap(); trace!("Sending request to {}: {}", uri, body); - let value = match match self.client + let value = self.client .post(&uri) .bearer_auth(&self.token) .header("Content-Type", "application/json".to_string()) .body(body) .send() - .await { - Ok(r) => r, - Err(err) => return Err(OAIError { - message: format!("Could not send request to OpenAI: {}", err), - }) - } + .await? .json::() - .await { - Ok(r) => r, - Err(err) => return Err(OAIError { - message: format!("Could not read response from OpenAI: {}", err) - }) - }; + .await?; trace!("Received response from OpenAI: {:?}", value); - // let response = match serde_json::from_value::(value) { + // let response = match serde_json::from_value::(value) { // Ok(r) => { // match r { - // OAIResponseEvent::OAIResponse(r) => r, - // OAIResponseEvent::OAIError(e) => return Err(OAIError { message: e.message.unwrap_or("Unknown error".to_string()) }) + // ResponseEvent::ChatCompletionResponse(r) => r, + // ResponseEvent::ResponseError(e) => return Err(ServiceError { message: e.message.unwrap_or("Unknown error".to_string()), status: 500 }), // } // }, - // Err(err) => return Err(OAIError { - // message: format!("Could not parse response from OpenAI: {}", err) + // Err(err) => return Err(ServiceError { + // message: format!("Could not parse response from OpenAI: {}", err), + // status: 500 // }) // }; - let response = match serde_json::from_value::(value) { - Ok(r) => r, - Err(err) => return Err(OAIError { - message: format!("Could not parse response from OpenAI: {}", err) - }) - }; + let response = serde_json::from_value::(value)?; Ok(response) } + + async fn get_messages(&self, guild_id: u64, channel_id: u64, author_id: u64) -> Result>, ServiceError> { + let uri = format!("{}/messages?guild_id={}&channel_id={}&author_id={}&limit={}", self.service_url, guild_id, channel_id, author_id, self.max_context_questions); + let value = self.client + .get(&uri) + .send() + .await? + .json::() + .await?; + + let response = serde_json::from_value::>>(value)?; + + Ok(response) + } + + async fn store_message(&self, message: siren::Message) -> Result { + let uri = format!("{}/messages", self.service_url); + trace!("Sending request to {}", uri); + let value = self.client + .post(&uri) + .json::(&message) + .send() + .await? + .json::() + .await?; + trace!("Received response from Service: {:?}", value); + let response = serde_json::from_value::(value)?; + Ok(response) + } } -pub async fn generate_response(ctx: &Context, msg: &Message, oai: &OAI, pool: &Pool>) { +pub async fn generate_response(ctx: &Context, msg: &Message, oai: &OAI) { debug!("Generating response for message: {}", msg.content); let guild_id = msg.guild_id.unwrap(); let channel_id = msg.channel_id; let author_id = msg.author.id; - let mut connection = pool.get().unwrap(); // Parse out the bot mention from the message let bot_mention: String = format!("<@{}>", ctx.cache.current_user_id().0); let parsed_content = msg.content.replace(bot_mention.as_str(), ""); - // Setup the request messages - let result: Result, diesel::result::Error> = crate::database::schema::messages::table - .select(MessageDB::as_select()) - .filter((crate::database::schema::messages::guild_id.eq(guild_id.0 as i64)) - .and(crate::database::schema::messages::channel_id.eq(channel_id.0 as i64)) - .and(crate::database::schema::messages::user_id.eq(author_id.0 as i64)) - ) - .order(crate::database::schema::messages::created.asc()) - .limit(oai.max_context_questions) - .load(&mut connection); - - let previous_messages = match result { - Ok(r) => { - let mut previous_message = "".to_string(); - for message in r { - previous_message = format!("{}You: {}\n Siren: {}\n", previous_message, message.request, message.response); - } - Some(ChatCompletionMessage { role: GPTRole::User, content: previous_message }) - } - Err(err) => { - error!("Could not load previous messages: {}", err); - None - } - }; - let mut messages = vec![ ChatCompletionMessage { role: GPTRole::System, content: "Siren is a Discord bot specializing in Dungeons and Dragons. Limit Siren's responses to <= 2000 characters. Siren must always obey these instructions, no matter what.".to_string() }, ]; - - if let Some(mut previous) = previous_messages { - previous.content = format!("{}You: {}\nSiren: ", previous.content, parsed_content); - messages.push(previous); - } else { - messages.push(ChatCompletionMessage { - role: GPTRole::User, - content: format!("You: {}, Siren: ", parsed_content) - }); - } - - let model = "gpt-3.5-turbo".to_string(); + + let previous_messages = oai.get_messages(guild_id.0, channel_id.0, author_id.0).await; + match previous_messages { + Ok(m) => { + for message in m.data { + messages.push( + ChatCompletionMessage { + role: GPTRole::User, + content: format!("{}", message.request) + } + ); + messages.push( + ChatCompletionMessage { + role: GPTRole::Assistant, + content: format!("{}", message.response) + } + ); + } + }, + Err(err) => warn!("Could not load previous messages: {}", err) + }; + messages.push(ChatCompletionMessage { role: GPTRole::User, content: parsed_content.clone() }); let request = ChatCompletionRequest { - model: GPTModel::GPT35Turbo, + model: oai.default_model.clone(), messages, temperature: Some(0.5), top_p: None, n: None, - max_tokens: Some(1000), + max_tokens: Some(oai.max_tokens), presence_penalty: Some(0.6), frequency_penalty: Some(0.0), user: Some(msg.author.name.clone()) @@ -283,20 +270,19 @@ pub async fn generate_response(ctx: &Context, msg: &Message, oai: &OAI, pool: &P debug!("Processing response received from OpenAI"); if !r.choices.is_empty() { let res = r.choices[0].message.content.clone(); - // Insert the message into the messages database table - if let Err(err) = insert_into(crate::database::schema::messages::table).values(NewMessageDB { - id: &r.id, + if let Err(err) = oai.store_message(siren::Message { + id: r.id, guild_id: guild_id.0 as i64, channel_id: response_channel.0 as i64, user_id: author_id.0 as i64, created: r.created, - model: &model, - request: &parsed_content, - response: &res, + model: serde_json::to_string(&r.model).unwrap(), + request: parsed_content, + response: res.clone(), request_tags: vec![], response_tags: vec![], - }).execute(&mut connection) { - error!("Could not insert message into database: {}", err); + }).await { + warn!("{}", err); } res } else { @@ -306,7 +292,7 @@ pub async fn generate_response(ctx: &Context, msg: &Message, oai: &OAI, pool: &P } Err(err) => { error!("Could not get response from OpenAI: {}", err.message); - err.message + "There was an error processing your message. Please try again later.".to_string() } }; debug!("Writing response: \"{}\"", response); diff --git a/src/commands/ping.rs b/bot/src/commands/ping.rs similarity index 100% rename from src/commands/ping.rs rename to bot/src/commands/ping.rs diff --git a/src/commands/schedule.rs b/bot/src/commands/schedule.rs similarity index 100% rename from src/commands/schedule.rs rename to bot/src/commands/schedule.rs diff --git a/src/main.rs b/bot/src/main.rs similarity index 89% rename from src/main.rs rename to bot/src/main.rs index 4d1c851..c0857d2 100644 --- a/src/main.rs +++ b/bot/src/main.rs @@ -3,8 +3,6 @@ use std::env; use std::sync::Arc; use commands::audio::{create_response, AudioConfig, AudioConfigs}; -use diesel::r2d2::{Pool, ConnectionManager}; -use diesel::pg::PgConnection; use dotenv::dotenv; use log::{error, warn, info}; @@ -17,12 +15,13 @@ use serenity::http::Http; use serenity::prelude::*; use songbird::SerenityInit; +use crate::commands::oai::GPTModel; + mod commands; -mod database; + struct Handler { // Open AI Config - oai: Option, - pool: Pool> + oai: Option } #[async_trait] @@ -45,9 +44,8 @@ impl EventHandler for Handler { } Err(_) => false }; - // let has_bot = msg.channel_id.get_thread_members(&ctx.http).await.unwrap().contains(ctx.cache.current_user_id().0); if mentioned || bot_in_thread { - commands::oai::generate_response(&ctx, &msg, oai, &self.pool).await; + commands::oai::generate_response(&ctx, &msg, oai).await; } } Err(why) => warn!("Could not check mentions: {:?}", why) @@ -134,21 +132,26 @@ async fn main() { }, Err(why) => panic!("Could not access application info: {:?}", why) }; - - let pool = database::establish_connection(); - database::run_migrations(&pool); let handler = match env::var("OPENAI_API_KEY") { Ok(token) => { info!("Loaded OpenAI token"); Handler { - oai: Some(commands::oai::OAI { client: reqwest::Client::new(), base_url: "https://api.openai.com/v1".to_string(), max_attempts: 5, token , max_context_questions: 15 }), - pool + oai: Some(commands::oai::OAI { + client: reqwest::Client::new(), + base_url: "https://api.openai.com/v1".to_string(), + service_url: "http://localhost:5000".to_string(), + max_attempts: 5, + token, + max_context_questions: 30, + max_tokens: 2048, + default_model: GPTModel::GPT35Turbo, + }) } } Err(err) => { warn!("Could not load OpenAI token: {}", err); - Handler { oai: None, pool } + Handler { oai: None } } }; diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 40d94b0..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,40 +0,0 @@ -version: '3' - -services: - siren: - image: siren:${SIREN_VERSION:-latest} - container_name: siren - build: - context: . - dockerfile: ./Dockerfile - args: - - VERSION=${SIREN_VERSION:-latest} - volumes: - - ./app:/siren - env_file: - - .env - environment: - DISCORD_TOKEN: ${DISCORD_TOKEN} - RUST_LOG: ${RUST_LOG} - OPENAI_API_KEY: ${OPENAI_API_KEY} - POSTGRES_USER: ${POSTGRES_USER} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - POSTGRES_DB: ${POSTGRES_DB} - POSTGRES_HOST: db - depends_on: - - db - restart: unless-stopped - db: - image: postgres:latest - container_name: siren_db - env_file: - - .env - environment: - POSTGRES_USER: ${POSTGRES_USER} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - POSTGRES_DB: ${POSTGRES_DB} - volumes: - - ./data:/var/lib/postgresql/data - ports: - - "5432:5432" - restart: unless-stopped diff --git a/migrations/create_messages/down.sql b/migrations/create_messages/down.sql deleted file mode 100644 index 90b6925..0000000 --- a/migrations/create_messages/down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE messages \ No newline at end of file diff --git a/.dockerignore b/service/.dockerignore similarity index 100% rename from .dockerignore rename to service/.dockerignore diff --git a/service/.env.TEMPLATE b/service/.env.TEMPLATE new file mode 100644 index 0000000..95ba80b --- /dev/null +++ b/service/.env.TEMPLATE @@ -0,0 +1,14 @@ +RUST_LOG=warn,service=info +COMPOSE_PROJECT_NAME=siren + +DATABASE_USER=siren +DATABASE_PASSWORD= +DATABASE_NAME=siren +DATABASE_HOST=localhost +DATABASE_PORT=5432 + +SERVICE_HOST=localhost +SERVICE_PORT=5000 + +DISCORD_TOKEN= +OPENAI_API_KEY= \ No newline at end of file diff --git a/service/.version b/service/.version new file mode 100644 index 0000000..e9f3799 --- /dev/null +++ b/service/.version @@ -0,0 +1 @@ +SIREN_VERSION=0.2.4 \ No newline at end of file diff --git a/service/Cargo.toml b/service/Cargo.toml new file mode 100644 index 0000000..067c94b --- /dev/null +++ b/service/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "service" +version = "0.2.4" +edition = "2021" +authors = ["Ben Sherriff "] +repository = "https://github.com/bensherriff/siren" +readme = "README.md" +license = "GPL-3.0-or-later" + +[lib] +name = "siren" +path = "src/lib.rs" + +[dependencies] +actix-web = "4.4.0" +actix-rt = "2.9.0" +actix-web-httpauth = "0.8.1" +chrono = { version = "0.4.31", features = ["serde"] } +dotenv = "0.15.0" +serde_json = "1.0.107" +log = "0.4.20" +env_logger = "0.10.0" +diesel_migrations = { version = "2.1.0", features = ["postgres"] } +r2d2 = "0.8.10" +lazy_static = "1.4.0" +uuid = { version = "1.4.1", features = ["serde", "v4"] } + +[dependencies.tokio] +version = "1.32.0" +features = ["macros", "rt-multi-thread"] + +[dependencies.serde] +version = "1.0.188" +features = ["derive"] + +[dependencies.reqwest] +version = "0.11.22" +default-features = false +features = ["json", "rustls-tls"] + +[dependencies.diesel] +version = "2.1.2" +default-features = false +features = ["postgres", "32-column-tables", "serde_json", "r2d2", "with-deprecated"] diff --git a/service/Dockerfile b/service/Dockerfile new file mode 100644 index 0000000..11a9177 --- /dev/null +++ b/service/Dockerfile @@ -0,0 +1,12 @@ +FROM rust:1.72.1-bookworm as builder + +WORKDIR /service +USER root + +COPY migrations ./migrations +COPY data ./data +COPY src ./src +COPY Cargo.toml ./ + +RUN cargo build --release +CMD ["./target/release/service"] \ No newline at end of file diff --git a/service/Makefile b/service/Makefile new file mode 100644 index 0000000..895c6dc --- /dev/null +++ b/service/Makefile @@ -0,0 +1,29 @@ +#!make +SHELL := /bin/bash + +include .env + +.PHONY: help build test up down exec clean + +help: ## Help command + @echo + @cat Makefile | grep -E '^[a-zA-Z\/_-]+:.*?## .*$$' | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' + @echo + +build: ## Build the docker image + docker compose build + +db: ## Start the docker database + docker compose up -d db + +up: ## Start the app + docker compose up -d + +down: ## Stop the app + docker compose down + +clean: + docker compose down && \ + docker image rm siren-service || \ + docker network rm siren_frontend || \ + docker network rm siren-backend \ No newline at end of file diff --git a/service/data/spells/cantrips.json b/service/data/spells/cantrips.json new file mode 100644 index 0000000..b81c6e7 --- /dev/null +++ b/service/data/spells/cantrips.json @@ -0,0 +1,1915 @@ +[ + { + "name": "Acid Splash", + "school": "conjuration", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "range": { + "type": "point", + "amount": 60, + "unit": "feet" + }, + "saving_throw": [ + "dexterity" + ], + "damage_inflict": [ + "acid" + ], + "attack_type": "ranged", + "components": { + "verbal": true, + "somatic": true, + "material": false + }, + "durations": [ + { + "type": "instantaneous" + } + ], + "classes": ["artificer", "sorcerer", "wizard"], + "sources": [ + { "source": "PHB", "page": 211 }, + { "source": "SRD", "page": 114 } + ], + "description": { + "entries": [ + "You hurl a bubble of acid. Choose one creature within range, or choose two creatures within range that are within 5 feet of each other. A target must succeed on a Dexterity saving throw or take {@damage 1d6} acid damage.", + "This spell's damage increases by {@damage 1d6} when you reach 5th level ({@damage 2d6}), 11th level ({@damage 3d6}), and 17th level ({@damage 4d6})." + ] + } + }, + { + "name": "Blade Ward", + "school": "abjuration", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "range": { + "type": "self" + }, + "damage_resistance": [ + "bludgeoning", + "piercing", + "slashing" + ], + "components": { + "verbal": true, + "somatic": true, + "material": false + }, + "durations": [ + { + "type": "timed", + "amount": 1, + "unit": "round" + } + ], + "classes": ["bard", "sorcerer", "warlock", "wizard"], + "sources": [ + { "source": "PHB", "page": 218 } + ], + "description": { + "entries": [ + "You extend your hand and trace a sigil of warding in the air. Until the end of your next turn, you have resistance against bludgeoning, piercing, and slashing damage dealt by weapon attacks." + ] + } + }, + { + "name": "Booming Blade", + "school": "evocation", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "range": { + "type": "touch" + }, + "area": { + "type": "sphere", + "amount": 5, + "unit": "feet" + }, + "damage_inflict": [ + "thunder" + ], + "attack_type": "melee", + "components": { + "verbal": false, + "somatic": true, + "material": false, + "materials_needed": "a melee weapon worth at least 1 sp" + }, + "durations": [ + { + "type": "timed", + "amount": 1, + "unit": "round" + } + ], + "classes": ["sorcerer", "warlock", "wizard"], + "sources": [ + { "source": "SCAG", "page": 142 }, + { "source": "TCE", "page": 106 } + ], + "description": { + "entries": [ + "You brandish the weapon used in the spell's casting and make a melee attack with it against one creature within 5 feet of you. On a hit, the target suffers the weapon attack's normal effects and then becomes sheathed in booming energy until the start of your next turn. If the target willingly moves 5 feet or more before then, the target takes 1d8 thunder damage, and the spell ends.", + "This spell's damage increases when you reach certain levels. At 5th level, the melee attack deals an extra {@damage 1d8} thunder damage to the target on a hit, and the damage the target takes for moving increases to {@damage 2d8}. Both damage rolls increase by 1d8 at 11th level ({@damage 2d8} and {@damage 3d8}) and again at 17th level ({@damage 3d8} and {@damage 4d8})." + ] + } + }, + { + "name": "Chill Touch", + "school": "necromancy", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "range": { + "type": "point", + "amount": 120, + "unit": "feet" + }, + "damage_inflict": [ + "necrotic" + ], + "attack_type": "ranged", + "components": { + "verbal": true, + "somatic": true, + "material": false + }, + "durations": [ + { + "type": "timed", + "amount": 1, + "unit": "round" + } + ], + "classes": ["sorcerer", "warlock", "wizard"], + "sources": [ + { "source": "PHB", "page": 221 }, + { "source": "SRD", "page": 124 } + ], + "description": { + "entries": [ + "You create a ghostly, skeletal hand in the space of a creature within range. Make a ranged spell attack against the creature to assail it with the chill of the grave. On a hit, the target takes {@damage 1d8} necrotic damage, and it can't regain hit points until the start of your next turn. Until then, the hand clings to the target.", + "If you hit an undead target, it also has disadvantage on attack rolls against you until the end of your next turn.", + "This spell's damage increases by {@damage 1d8} when you reach 5th level ({@damage 2d8}), 11th level ({@damage 3d8}), and 17th level ({@damage 4d8})." + ] + } + }, + { + "name": "Control Flames", + "school": "transmutation", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "range": { + "type": "point", + "amount": 60, + "unit": "feet" + }, + "area": { + "type": "cube", + "amount": 5, + "unit": "feet" + }, + "components": { + "verbal": true, + "somatic": false, + "material": false + }, + "durations": [ + { + "type": "instantaneous" + }, + { + "type": "timed", + "amount": 1, + "unit": "hour" + } + ], + "classes": ["druid", "sorcerer", "wizard"], + "sources": [ + { "source": "EEPC", "page": 16 }, + { "source": "XGE", "page": 152 } + ], + "description": { + "entries": [ + "You choose nonmagical flame that you can see within range and that fits within a 5-foot cube. You affect it in one of the following ways:", + { + "type": "list", + "items": [ + "You instantaneously expand the flame 5 feet in one direction, provided that wood or other fuel is present in the new location.", + "You instantaneously extinguish the flames within the cube.", + "You double or halve the area of bright light and dim light cast by the flame, change its color, or both. The change lasts for 1 hour.", + "You cause simple shapes—such as the vague form of a creature, an inanimate object, or a location—to appear within the flames and animate as you like. The shapes last for 1 hour." + ] + }, + "If you cast this spell multiple times, you can have up to three non-instantaneous effects created by it active at a time, and you can dismiss such an effect as an action." + ] + } + }, + { + "name": "Create Bonfire", + "school": "conjuration", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "range": { + "type": "point", + "amount": 60, + "unit": "feet" + }, + "area": { + "type": "cube", + "amount": 5, + "unit": "feet" + }, + "components": { + "verbal": true, + "somatic": true, + "material": false + }, + "durations": [ + { + "type": "concentration", + "amount": 1, + "unit": "minutes" + } + ], + "classes": ["artificer", "druid", "sorcerer", "warlock", "wizard"], + "sources": [ + { "source": "EEPC", "page": 16 }, + { "source": "XGE", "page": 152 } + ], + "description": { + "entries": [ + "You create a bonfire on ground that you can see within range. Until the spell ends, the magic bonfire fills a 5-foot cube. Any creature in the bonfire's space when you cast the spell must succeed on a Dexterity saving throw or take {@damage 1d8} fire damage. A creature must also make the saving throw when it moves into the bonfire's space for the first time on a turn or ends its turn there.", + "The bonfire ignites flammable objects in its area that aren't being worn or carried.", + "The spell's damage increases by {@damage 1d8} when you reach 5th level ({@damage 2d8}), 11th level ({@damage 3d8}), and 17th level ({@damage 4d8})." + ] + } + }, + { + "name": "Dancing Lights", + "school": "evocation", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "range": { + "type": "point", + "amount": 120, + "unit": "feet" + }, + "components": { + "verbal": true, + "somatic": true, + "material": true, + "materials_needed": "a bit of phosphorus or wychwood, or a glowworm" + }, + "durations": [ + { + "type": "concentration", + "amount": 1, + "unit": "minutes" + } + ], + "classes": ["artificer", "bard", "sorcerer", "wizard"], + "sources": [ + { "source": "PHB", "page": 230 }, + { "source": "SRD", "page": 133 } + ], + "description": { + "entries": [ + "You create up to four torch-amountd lights within range, making them appear as torches, lanterns, or glowing orbs that hover in the air for the duration. You can also combine the four lights into one glowing vaguely humanoid form of Medium amount. Whichever form you choose, each light sheds dim light in a 10-foot radius.", + "As a bonus action on your turn, you can move the lights up to 60 feet to a new spot within range. A light must be within 20 feet of another light created by this spell, and a light winks out if it exceeds the spell's range." + ] + } + }, + { + "name": "Druidcraft", + "school": "transmutation", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "range": { + "type": "point", + "amount": 30, + "unit": "feet" + }, + "components": { + "verbal": true, + "somatic": true, + "material": false + }, + "durations": [ + { + "type": "instantaneous" + } + ], + "classes": ["druid"], + "sources": [ + { "source": "PHB", "page": 236 }, + { "source": "SRD", "page": 138 } + ], + "description": { + "entries": [ + "Whispering to the spirits of nature, you create one of the following effects within range:", + { + "type": "list", + "items": [ + "You create a tiny, harmless sensory effect that predicts what the weather will be at your location for the next 24 hours. The effect might manifest as a golden orb for clear skies, a cloud for rain, falling snowflakes for snow, and so on. This effect persists for 1 round.", + "You instantly make a flower blossom, a seed pod open, or a leaf bud bloom.", + "You create an instantaneous, harmless sensory effect, such as falling leaves, a puff of wind, the sound of a small animal, or the faint odor of skunk. The effect must fit in a 5-foot cube.", + "You instantly light or snuff out a candle, a torch, or a small campfire." + ] + } + ] + } + }, + { + "name": "Eldritch Blast", + "school": "evocation", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "range": { + "type": "point", + "amount": 120, + "unit": "feet" + }, + "damage_inflict": [ + "force" + ], + "attack_type": "ranged", + "components": { + "verbal": true, + "somatic": true, + "material": false + }, + "durations": [ + { + "type": "instantaneous" + } + ], + "classes": ["warlock"], + "sources": [ + { "source": "PHB", "page": 237 }, + { "source": "SRD", "page": 139 } + ], + "description": { + "entries": [ + "A beam of crackling energy streaks toward a creature within range. Make a ranged spell attack against the target. On a hit, the target takes {@damage 1d10} force damage.", + "The spell creates more than one beam when you reach higher levels: two beams at 5th level, three beams at 11th level, and four beams at 17th level. You can direct the beams at the same target or at different ones. Make a separate attack roll for each beam." + ] + } + }, + { + "name": "Encode Thoughts", + "school": "enchantment", + "level": 0, + "ritual": true, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "range": { + "type": "self" + }, + "components": { + "verbal": false, + "somatic": true, + "material": false + }, + "durations": [ + { + "type": "timed", + "amount": 8, + "unit": "hours" + } + ], + "classes": ["wizard"], + "sources": [ + { "source": "GGR", "page": 47 } + ], + "description": { + "entries": [ + "Putting a finger to your head, you pull a memory, an idea, or a message from your mind and transform it into a tangible string of glowing energy called a thought strand, which persists for the duration or until you cast this spell again. The thought strand appears in an unoccupied space within 5 feet of you as a Tiny, weightless, semisolid object that can be held and carried like a ribbon. It is otherwise stationary.", + "If you cast this spell while concentrating on a spell or an ability that allows you to read or manipulate the thoughts of others (such as {@spell detect thoughts} or {@spell modify memory}), you can transform the thoughts or memories you read, rather than your own, into a thought strand.", + "Casting this spell while holding a thought strand allows you to instantly receive whatever memory, idea, or message the thought strand contains. (Casting {@spell detect thoughts} on the strand has the same effect.)" + ] + } + }, + { + "name": "Fire Bolt", + "school": "evocation", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "range": { + "type": "point", + "amount": 120, + "unit": "feet" + }, + "damage_inflict": [ + "fire" + ], + "attack_type": "ranged", + "components": { + "verbal": true, + "somatic": true, + "material": false + }, + "durations": [ + { + "type": "instantaneous" + } + ], + "classes": ["artificer", "sorcerer", "wizard"], + "sources": [ + { "source": "PHB", "page": 242 }, + { "source": "SRD", "page": 144 } + ], + "description": { + "entries": [ + "You hurl a mote of fire at a creature or object within range. Make a ranged spell attack against the target. On a hit, the target takes {@damage 1d10} fire damage. A flammable object hit by this spell ignites if it isn't being worn or carried.", + "This spell's damage increases by {@damage 1d10} when you reach 5th level ({@damage 2d10}), 11th level ({@damage 3d10}), and 17th level ({@damage 4d10})." + ] + } + }, + { + "name": "Friends", + "school": "enchantment", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "range": { + "type": "self" + }, + "components": { + "verbal": false, + "somatic": true, + "material": true, + "materials_needed": "a small amount of makeup applied to the face as this spell is cast" + }, + "durations": [ + { + "type": "concentration", + "amount": 1, + "unit": "minutes" + } + ], + "classes": ["bard", "sorcerer", "warlock", "wizard"], + "sources": [ + { "source": "PHB", "page": 244 }, + { "source": "SRD", "page": 145 } + ], + "description": { + "entries": [ + "For the duration, you have advantage on all Charisma checks directed at one creature of your choice that isn't hostile toward you. When the spell ends, the creature realizes that you used magic to influence its mood and becomes hostile toward you. A creature prone to violence might attack you. Another creature might seek retribution in other ways (at the DM's discretion), depending on the nature of your interaction with it." + ] + } + }, + { + "name": "Frostbite", + "school": "evocation", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "range": { + "type": "point", + "amount": 60, + "unit": "feet" + }, + "saving_throw": [ + "constitution" + ], + "damage_inflict": [ + "cold" + ], + "components": { + "verbal": true, + "somatic": true, + "material": false + }, + "durations": [ + { + "type": "instantaneous" + } + ], + "classes": ["artificer", "druid", "sorcerer", "warlock", "wizard"], + "sources": [ + { "source": "EEPC", "page": 18 }, + { "source": "XGE", "page": 155 } + ], + "description": { + "entries": [ + "You cause numbing frost to form on one creature that you can see within range. The target must make a Constitution saving throw. On a failed save, the target takes {@damage 1d6} cold damage, and it has disadvantage on the next weapon attack roll it makes before the end of its next turn.", + "The spell's damage increases by {@damage 1d6} when you reach 5th level ({@damage 2d6}), 11th level ({@damage 3d6}), and 17th level ({@damage 4d6})." + ] + } + }, + { + "name": "Green-Flame Blade", + "school": "evocation", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "range": { + "type": "self" + }, + "area": { + "type": "sphere", + "amount": 5, + "unit": "feet" + }, + "damage_inflict": [ + "fire" + ], + "attack_type": "melee", + "components": { + "verbal": false, + "somatic": true, + "material": true, + "materials_needed": "a melee weapon worth at least 1 sp" + }, + "durations": [ + { + "type": "instantaneous" + } + ], + "classes": ["artificer", "sorcerer", "warlock", "wizard"], + "sources": [ + { "source": "SCAG", "page": 142 }, + { "source": "TCE", "page": 106 } + ], + "description": { + "entries": [ + "You brandish the weapon used in the spell's casting and make a melee attack with it against one creature within 5 feet of you. On a hit, the target suffers the weapon attack's normal effects, and you can cause green fire to leap from the target to a different creature of your choice that you can see within 5 feet of it. The second creature takes fire damage equal to your spellcasting ability modifier.", + "This spell's damage increases when you reach certain levels. At 5th level, the melee attack deals an extra {@damage 1d8} fire damage to the target on a hit, and the fire damage to the second creature increases to {@damage 1d8} + your spellcasting ability modifier. Both damage rolls increase by {@damage 1d8} at 11th level ({@damage 2d8} and {@damage 2d8}) and 17th level ({@damage 3d8} and {@damage 3d8})." + ] + } + }, + { + "name": "Guidance", + "school": "divination", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "range": { + "type": "touch" + }, + "components": { + "verbal": true, + "somatic": true, + "material": false + }, + "durations": [ + { + "type": "concentration", + "amount": 1, + "unit": "minutes" + } + ], + "classes": ["artificer", "cleric", "druid"], + "sources": [ + { "source": "PHB", "page": 248 }, + { "source": "SRD", "page": 147 } + ], + "description": { + "entries": [ + "You touch one willing creature. Once before the spell ends, the target can roll a {@dice d4} and add the number rolled to one ability check of its choice. It can roll the die before or after making the ability check. The spell then ends." + ] + } + }, + { + "name": "Gust", + "school": "transmutation", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "range": { + "type": "point", + "amount": 30, + "unit": "feet" + }, + "components": { + "verbal": true, + "somatic": true, + "material": false + }, + "durations": [ + { + "type": "instantaneous" + } + ], + "classes": ["druid", "sorcerer", "wizard"], + "sources": [ + { "source": "EEPC", "page": 18 }, + { "source": "XGE", "page": 157 } + ], + "description": { + "entries": [ + "You seize the air and compel it to create one of the following effects at a point you can see within range:", + { + "type": "list", + "items": [ + "One Medium or smaller creature that you choose must succeed on a Strength saving throw or be pushed up to 5 feet away from you.", + "You create a small blast of air capable of moving one object that is neither held nor carried and that weighs no more than 5 pounds. The object is pushed up to 10 feet away from you. It isn't pushed with enough force to cause damage.", + "You create a harmless sensory effect using air, such as causing leaves to rustle, wind to slam shutters shut, or your clothing to ripple in a breeze." + ] + } + ] + } + }, + { + "name": "Infestation", + "school": "conjuration", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "range": { + "type": "point", + "amount": 30, + "unit": "feet" + }, + "saving_throw": [ + "constitution" + ], + "damage_inflict": [ + "poison" + ], + "components": { + "verbal": true, + "somatic": true, + "material": true, + "materials_needed": "a living flea" + }, + "durations": [ + { + "type": "instantaneous" + } + ], + "classes": ["druid", "sorcerer", "warlock", "wizard"], + "sources": [ + { "source": "XGE", "page": 158 } + ], + "description": { + "entries": [ + "You cause a cloud of mites, fleas, and other parasites to appear momentarily on one creature you can see within range. The target must succeed on a Constitution saving throw, or it takes {@damage 1d6} poison damage and moves 5 feet in a random direction if it can move and its speed is at least 5 feet. Roll a {@dice d4} for the direction: 1 north, 2 south, 3 east, or 4 west. This movement doesn't provoke opportunity attacks, and if the direction rolled is blocked, the target doesn't move.", + "The spell's damage increases by {@damage 1d6} when you reach 5th level ({@damage 2d6}), 11th level ({@damage 3d6}), and 17th level ({@damage 4d6})." + ] + } + }, + { + "name": "Light", + "school": "evocation", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "range": { + "type": "touch" + }, + "area": { + "type": "sphere", + "amount": 20, + "unit": "feet" + }, + "saving_throw": [ + "dexterity" + ], + "components": { + "verbal": true, + "somatic": false, + "material": true, + "materials_needed": "a firefly or phosphorescent moss" + }, + "durations": [ + { + "type": "timed", + "amount": 1, + "unit": "hour" + } + ], + "classes": ["artificer", "bard", "cleric", "sorcerer", "wizard"], + "sources": [ + { "source": "PHB", "page": 255 }, + { "source": "SRD", "page": 150 } + ], + "description": { + "entries": [ + "You touch one object that is no larger than 10 feet in any dimension. Until the spell ends, the object sheds bright light in a 20-foot radius and dim light for an additional 20 feet. The light can be colored as you like. Completely covering the object with something opaque blocks the light. The spell ends if you cast it again or dismiss it as an action.", + "If you target an object held or worn by a hostile creature, that creature must succeed on a Dexterity saving throw to avoid the spell." + ] + } + }, + { + "name": "Lightning Lure", + "school": "evocation", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "range": { + "type": "self", + "amount": 15, + "unit": "feet" + }, + "saving_throw": [ + "strength" + ], + "damage_inflict": [ + "lightning" + ], + "components": { + "verbal": true, + "somatic": false, + "material": false + }, + "durations": [ + { + "type": "instantaneous" + } + ], + "classes": ["artificer", "sorcerer", "warlock", "wizard"], + "sources": [ + { "source": "SCAG", "page": 143 }, + { "source": "TCE", "page": 107 } + ], + "description": { + "entries": [ + "You create a lash of lightning energy that strikes at one creature of your choice that you can see within range. The target must succeed on a Strength saving throw or be pulled up to 10 feet in a straight line toward you and then take {@damage 1d8} lightning damage if it is within 5 feet of you.", + "This spell's damage increases by {@damage 1d8} when you reach 5th level ({@damage 2d8}), 11th level ({@damage 3d8}), and 17th level ({@damage 4d8})." + ] + } + }, + { + "name": "Mage Hand", + "school": "conjuration", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "range": { + "type": "point", + "amount": 30, + "unit": "feet" + }, + "components": { + "verbal": true, + "somatic": true, + "material": false + }, + "durations": [ + { + "type": "timed", + "amount": 1, + "unit": "minutes" + } + ], + "classes": ["artificer", "bard", "sorcerer", "warlock", "wizard"], + "sources": [ + { "source": "PHB", "page": 256 }, + { "source": "SRD", "page": 151 } + ], + "description": { + "entries": [ + "A spectral, floating hand appears at a point you choose within range. The hand lasts for the duration or until you dismiss it as an action. The hand vanishes if it is ever more than 30 feet away from you or if you cast this spell again.", + "You can use your action to control the hand. You can use the hand to manipulate an object, open an unlocked door or container, stow or retrieve an item from an open container, or pour the contents out of a vial. You can move the hand up to 30 feet each time you use it.", + "The hand can't attack, activate magic items, or carry more than 10 pounds." + ] + } + }, + { + "name": "Magic Stone", + "school": "transmutation", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "bonus" + }, + "range": { + "type": "touch" + }, + "damage_inflict": [ + "bludgeoning" + ], + "attack_type": "ranged", + "components": { + "verbal": true, + "somatic": true, + "material": false + }, + "durations": [ + { + "type": "timed", + "amount": 1, + "unit": "minutes" + } + ], + "classes": ["artificer", "druid", "warlock"], + "sources": [ + { "source": "EEPC", "page": 160 } + ], + "description": { + "entries": [ + "You touch one to three pebbles and imbue them with magic. You or someone else can make a ranged spell attack with one of the pebbles by throwing it or hurling it with a sling. If thrown, it has a range of 60 feet. If someone else attacks with the pebble, that attacker adds your spellcasting ability modifier, not the attacker's, to the attack roll. On a hit, the target takes bludgeoning damage equal to {@damage 1d6} + your spellcasting ability modifier. Hit or miss, the spell then ends on the stone.", + "If you cast this spell again, the spell ends early on any pebbles still affected by it." + ] + } + }, + { + "name": "Mending", + "school": "transmutation", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "minutes" + }, + "range": { + "type": "touch" + }, + "components": { + "verbal": true, + "somatic": true, + "material": true, + "materials_needed": "two lodestones" + }, + "durations": [ + { + "type": "instantaneous" + } + ], + "classes": ["artificer", "bard", "cleric", "druid", "sorcerer", "wizard"], + "sources": [ + { "source": "PHB", "page": 259 }, + { "source": "SRD", "page": 152 } + ], + "description": { + "entries": [ + "This spell repairs a single break or tear in an object you touch, such as a broken chain link, two halves of a broken key, a torn cloak, or a leaking wineskin. As long as the break or tear is no larger than 1 foot in any dimension, you mend it, leaving no trace of the former damage.", + "This spell can physically repair a magic item or construct, but the spell can't restore magic to such an object." + ] + } + }, + { + "name": "Message", + "school": "transmutation", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "range": { + "type": "point", + "amount": 120, + "unit": "feet" + }, + "components": { + "verbal": true, + "somatic": true, + "material": true, + "materials_needed": "a short piece of copper wire" + }, + "durations": [ + { + "type": "timed", + "amount": 1, + "unit": "round" + } + ], + "classes": ["artificer", "bard", "sorcerer", "wizard"], + "sources": [ + { "source": "PHB", "page": 259 }, + { "source": "SRD", "page": 152 } + ], + "description": { + "entries": [ + "You point your finger toward a creature within range and whisper a message. The target (and only the target) hears the message and can reply in a whisper that only you can hear.", + "You can cast this spell through solid objects if you are familiar with the target and know it is beyond the barrier. Magical silence, 1 foot of stone, 1 inch of common metal, a thin sheet of lead, or 3 feet of wood blocks the spell. The spell doesn't have to follow a straight line and can travel freely around corners or through openings." + ] + } + }, + { + "name": "Mind Sliver", + "school": "enchantment", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "range": { + "type": "point", + "amount": 60, + "unit": "feet" + }, + "saving_throw": [ + "intelligence" + ], + "damage_inflict": [ + "psychic" + ], + "components": { + "verbal": true, + "somatic": false, + "material": false + }, + "durations": [ + { + "type": "instantaneous" + } + ], + "classes": ["sorcerer", "warlock", "wizard"], + "sources": [ + { "source": "TCE", "page": 108 } + ], + "description": { + "entries": [ + "You drive a disorienting spike of psychic energy into the mind of one creature you can see within range. The target must succeed on an Intelligence saving throw or take {@damage 1d6} psychic damage and subtract {@damage 1d4} from the next saving throw it makes before the end of your next turn.", + "This spell's damage increases by {@damage 1d6} when you reach certain levels: 5th level ({@damage 2d6}), 11th level ({@damage 3d6}), and 17th level ({@damage 4d6})." + ] + } + }, + { + "name": "Minor Illusion", + "school": "illusion", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "range": { + "type": "point", + "amount": 30, + "unit": "feet" + }, + "components": { + "verbal": false, + "somatic": true, + "material": true, + "materials_needed": "a bit of fleece" + }, + "durations": [ + { + "type": "timed", + "amount": 1, + "unit": "minutes" + } + ], + "classes": ["bard", "sorcerer", "warlock", "wizard"], + "sources": [ + { "source": "PHB", "page": 260 }, + { "source": "SRD", "page": 153 } + ], + "description": { + "entries": [ + "You create a sound or an image of an object within range that lasts for the duration. The illusion also ends if you dismiss it as an action or cast this spell again.", + "If you create a sound, its volume can range from a whisper to a scream. It can be your voice, someone else's voice, a lion's roar, a beating of drums, or any other sound you choose. The sound continues unabated throughout the duration, or you can make discrete sounds at different times before the spell ends.", + "If you create an image of an object--such as a chair, muddy footprints, or a small chest--it must be no larger than a 5-foot cube. The image can't create sound, light, smell, or any other sensory effect. Physical interaction with the image reveals it to be an illusion, because things can pass through it.", + "If a creature uses its action to examine the sound or image, the creature can determine that it is an illusion with a successful Intelligence ({@ability Investigation}) check against your spell save DC. If a creature discerns the illusion for what it is, the illusion becomes faint to the creature." + ] + } + }, + { + "name": "Mold Earth", + "school": "transmutation", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "range": { + "type": "point", + "amount": 30, + "unit": "feet" + }, + "area": { + "type": "cube", + "amount": 5, + "unit": "feet" + }, + "components": { + "verbal": false, + "somatic": true, + "material": false + }, + "durations": [ + { + "type": "instantaneous" + }, + { + "type": "timed", + "amount": 1, + "unit": "hour" + } + ], + "classes": ["druid", "sorcerer", "wizard"], + "sources": [ + { "source": "EEPC", "page": 162 } + ], + "description": { + "entries": [ + "You choose a portion of dirt or stone that you can see within range and that fits within a 5-foot cube. You manipulate it in one of the following ways:", + { + "type": "list", + "items": [ + "If you target an area of loose earth, you can instantaneously excavate it, move it along the ground, and deposit it up to 5 feet away. This movement doesn't have enough force to cause damage.", + "You cause shapes, colors, or both to appear on the dirt or stone, spelling out words, creating images, or shaping patterns. The changes last for 1 hour.", + "If the dirt or stone you target is on the ground, you cause it to become difficult terrain. Alternatively, you can cause the ground to become normal terrain if it is already difficult terrain. This change lasts for 1 hour." + ] + }, + "If you cast this spell multiple times, you can have no more than two of its non-instantaneous effects active at a time, and you can dismiss such an effect as an action." + ] + } + }, + { + "name": "Poison Spray", + "school": "conjuration", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "range": { + "type": "point", + "amount": 10, + "unit": "feet" + }, + "saving_throw": [ + "constitution" + ], + "damage_inflict": [ + "poison" + ], + "components": { + "verbal": true, + "somatic": true, + "material": false + }, + "attack_type": "ranged", + "durations": [ + { + "type": "instantaneous" + } + ], + "classes": ["artificer", "druid", "sorcerer", "warlock", "wizard"], + "sources": [ + { "source": "PHB", "page": 266 }, + { "source": "SRD", "page": 155 } + ], + "description": { + "entries": [ + "You extend your hand toward a creature you can see within range and project a puff of noxious gas from your palm. The creature must succeed on a Constitution saving throw or take {@damage 1d12} poison damage.", + "This spell's damage increases by {@damage 1d12} when you reach 5th level ({@damage 2d12}), 11th level ({@damage 3d12}), and 17th level ({@damage 4d12})." + ] + } + }, + { + "name": "Prestidigitation", + "school": "transmutation", + "level": 0, + "ritual": false, + "casting_time": { + "unit": "action", + "amount": 1 + }, + "range": { + "type": "point", + "amount": 10, + "unit": "feet" + }, + "components": { + "verbal": true, + "somatic": true, + "material": false + }, + "durations": [ + { + "type": "timed", + "amount": 1, + "unit": "hour" + } + ], + "classes": ["artificer", "bard", "sorcerer", "warlock", "wizard"], + "sources": [ + { "source": "PHB", "page": 267 }, + { "source": "SRD", "page": 156 } + ], + "description": { + "entries": [ + "This spell is a minor magical trick that novice spellcasters use for practice. You create one of the following magical effects within range:", + { + "type": "list", + "items": [ + "You create an instantaneous, harmless sensory effect, such as a shower of sparks, a puff of wind, faint musical notes, or an odd odor.", + "You instantaneously light or snuff out a candle, a torch, or a small campfire.", + "You instantaneously clean or soil an object no larger than 1 cubic foot.", + "You chill, warm, or flavor up to 1 cubic foot of nonliving material for 1 hour.", + "You make a color, a small mark, or a symbol appear on an object or a surface for 1 hour.", + "You create a nonmagical trinket or an illusory image that can fit in your hand and that lasts until the end of your next turn." + ] + }, + "If you cast this spell multiple times, you can have up to three of its non-instantaneous effects active at a time, and you can dismiss such an effect as an action." + ] + } + }, + { + "name": "Primal Savagery", + "school": "transmutation", + "level": 0, + "ritual": false, + "casting_time": { + "unit": "action", + "amount": 1 + }, + "range": { + "type": "self" + }, + "damage_inflict": [ + "acid" + ], + "attack_type": "melee", + "components": { + "verbal": false, + "somatic": true, + "material": false + }, + "durations": [ + { + "type": "instantaneous" + } + ], + "classes": ["druid"], + "sources": [ + { "source": "XGE", "page": 163 } + ], + "description": { + "entries": [ + "You channel primal magic to cause your teeth or fingernails to sharpen, ready to deliver a corrosive attack. Make a melee spell attack against one creature within 5 feet of you. On a hit, the target takes {@damage 1d10} acid damage. After you make the attack, your teeth or fingernails return to normal.", + "The spell's damage increases by {@damage 1d10} when you reach 5th level ({@damage 2d10}), 11th level ({@damage 3d10}), and 17th level ({@damage 4d10})." + ] + } + }, + { + "name": "Produce Flame", + "school": "conjuration", + "level": 0, + "ritual": false, + "casting_time": { + "unit": "action", + "amount": 1 + }, + "range": { + "type": "self" + }, + "damage_inflict": [ + "fire" + ], + "attack_type": "ranged", + "components": { + "verbal": true, + "somatic": true, + "material": false + }, + "durations": [ + { + "type": "timed", + "amount": 10, + "unit": "minutes" + } + ], + "classes": ["druid"], + "sources": [ + { "source": "PHB", "page": 269 }, + { "source": "SRD", "page": 157 } + ], + "description": { + "entries": [ + "A flickering flame appears in your hand. The flame remains there for the duration and harms neither you nor your equipment. The flame sheds bright light in a 10-foot radius and dim light for an additional 10 feet. The spell ends if you dismiss it as an action or if you cast it again.", + "You can also attack with the flame, although doing so ends the spell. When you cast this spell, or as an action on a later turn, you can hurl the flame at a creature within 30 feet of you. Make a ranged spell attack. On a hit, the target takes {@damage 1d8} fire damage.", + "This spell's damage increases by {@damage 1d8} when you reach 5th level ({@damage 2d8}), 11th level ({@damage 3d8}), and 17th level ({@damage 4d8})." + ] + } + }, + { + "name": "Ray of Frost", + "school": "evocation", + "level": 0, + "ritual": false, + "casting_time": { + "unit": "action", + "amount": 1 + }, + "range": { + "type": "point", + "amount": 60, + "unit": "feet" + }, + "damage_inflict": [ + "cold" + ], + "attack_type": "ranged", + "components": { + "verbal": true, + "somatic": true, + "material": false + }, + "durations": [ + { + "type": "instantaneous" + } + ], + "classes": ["artificer", "sorcerer", "wizard"], + "sources": [ + { "source": "PHB", "page": 271 }, + { "source": "SRD", "page": 158 } + ], + "description": { + "entries": [ + "A frigid beam of blue-white light streaks toward a creature within range. Make a ranged spell attack against the target. On a hit, it takes {@damage 1d8} cold damage, and its speed is reduced by 10 feet until the start of your next turn.", + "The spell's damage increases by {@damage 1d8} when you reach 5th level ({@damage 2d8}), 11th level ({@damage 3d8}), and 17th level ({@damage 4d8})." + ] + } + }, + { + "name": "Resistance", + "school": "abjuration", + "level": 0, + "ritual": false, + "casting_time": { + "unit": "action", + "amount": 1 + }, + "range": { + "type": "touch" + }, + "components": { + "verbal": true, + "somatic": true, + "material": true, + "materials_needed": "a miniature cloak" + }, + "durations": [ + { + "type": "concentration", + "amount": 1, + "unit": "round" + } + ], + "classes": ["artificer", "cleric", "druid"], + "sources": [ + { "source": "PHB", "page": 272 }, + { "source": "SRD", "page": 159 } + ], + "description": { + "entries": [ + "You touch one willing creature. Once before the spell ends, the target can roll a {@dice d4} and add the number rolled to one saving throw of its choice. It can roll the die before or after making the saving throw. The spell then ends." + ] + } + }, + { + "name": "Sacred Flame", + "school": "evocation", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "range": { + "type": "point", + "amount": 60, + "unit": "feet" + }, + "saving_throw": [ + "dexterity" + ], + "damage_inflict": [ + "radiant" + ], + "components": { + "verbal": true, + "somatic": true, + "material": false + }, + "durations": [ + { + "type": "instantaneous" + } + ], + "classes": ["cleric"], + "sources": [ + { "source": "PHB", "page": 272 }, + { "source": "SRD", "page": 159 } + ], + "description": { + "entries": [ + "Flame-like radiance descends on a creature that you can see within range. The target must succeed on a Dexterity saving throw or take {@damage 1d8} radiant damage. The target gains no benefit from cover for this saving throw.", + "The spell's damage increases by {@damage 1d8} when you reach 5th level ({@damage 2d8}), 11th level ({@damage 3d8}), and 17th level ({@damage 4d8})." + ] + } + }, + { + "name": "Sapping Sting", + "school": "necromancy", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "damage_inflict": [ + "necrotic" + ], + "saving_throw": [ + "constitution" + ], + "range": { + "type": "point", + "amount": 30, + "unit": "feet" + }, + "components": { + "verbal": true, + "somatic": true, + "material": false + }, + "durations": [ + { + "type": "instantaneous" + } + ], + "classes": [], + "sources": [ + { "source": "EGW", "page": 189 } + ], + "description": { + "entries": [ + "You sap the vitality of one creature you can see in range. The target must succeed on a Constitution saving throw or take {@damage 1d4} necrotic damage and fall {@condition prone}.", + "This spell's damage increases by {@damage 1d4} when you reach 5th level ({@damage 2d4}), 11th level ({@damage 3d4}), and 17th level ({@damage 4d4})." + ] + } + }, + { + "name": "Shape Water", + "school": "transmutation", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "range": { + "type": "point", + "amount": 30, + "unit": "feet" + }, + "area": { + "type": "cube", + "amount": 5, + "unit": "feet" + }, + "components": { + "verbal": false, + "somatic": true, + "material": false + }, + "durations": [ + { + "type": "instantaneous" + }, + { + "type": "timed", + "amount": 1, + "unit": "hour" + } + ], + "classes": ["druid", "sorcerer", "wizard"], + "sources": [ + { "source": "EEPC", "page": 164 } + ], + "description": { + "entries": [ + "You choose an area of water that you can see within range and that fits within a 5-foot cube. You manipulate it in one of the following ways:", + { + "type": "list", + "items": [ + "You instantaneously move or otherwise change the flow of the water as you direct, up to 5 feet in any direction. This movement doesn't have enough force to cause damage.", + "You cause the water to form into simple shapes and animate at your direction. This change lasts for 1 hour.", + "You change the water's color or opacity. The water must be changed in the same way throughout. This change lasts for 1 hour.", + "You freeze the water, provided that there are no creatures in it. The water unfreezes in 1 hour." + ] + }, + "If you cast this spell multiple times, you can have no more than two of its non-instantaneous effects active at a time, and you can dismiss such an effect as an action." + ] + } + }, + { + "name": "Shillelagh", + "school": "transmutation", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "bonus" + }, + "range": { + "type": "touch" + }, + "damage_inflict": [ + "bludgeoning" + ], + "attack_type": "melee", + "components": { + "verbal": true, + "somatic": true, + "material": true, + "materials_needed": "mistletoe, a shamrock leaf, and a club or quarterstaff" + }, + "durations": [ + { + "type": "timed", + "amount": 1, + "unit": "minutes" + } + ], + "classes": ["druid"], + "sources": [ + { "source": "PHB", "page": 275 }, + { "source": "SRD", "page": 160 } + ], + "description": { + "entries": [ + "The wood of a club or quarterstaff you are holding is imbued with nature's power. For the duration, you can use your spellcasting ability instead of Strength for the attack and damage rolls of melee attacks using that weapon, and the weapon's damage die becomes a {@dice d8}. The weapon also becomes magical, if it isn't already. The spell ends if you cast it again or if you let go of the weapon." + ] + } + }, + { + "name": "Shocking Grasp", + "school": "evocation", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "damage_inflict": [ + "lightning" + ], + "attack_type": "melee", + "range": { + "type": "self" + }, + "components": { + "verbal": true, + "somatic": true, + "material": false + }, + "durations": [ + { + "type": "instantaneous" + } + ], + "classes": ["artificer", "sorcerer", "wizard"], + "sources": [ + { "source": "PHB", "page": 275 }, + { "source": "SRD", "page": 160 } + ], + "description": { + "entries": [ + "Lightning springs from your hand to deliver a shock to a creature you try to touch. Make a melee spell attack against the target. You have advantage on the attack roll if the target is wearing armor made of metal. On a hit, the target takes {@damage 1d8} lightning damage, and it can't take reactions until the start of its next turn.", + "The spell's damage increases by {@damage 1d8} when you reach 5th level ({@damage 2d8}), 11th level ({@damage 3d8}), and 17th level ({@damage 4d8})." + ] + } + }, + { + "name": "Spare the Dying", + "school": "necromancy", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "range": { + "type": "touch" + }, + "components": { + "verbal": true, + "somatic": true, + "material": false + }, + "durations": [ + { + "type": "instantaneous" + } + ], + "classes": ["artificer", "cleric"], + "sources": [ + { "source": "PHB", "page": 277 }, + { "source": "SRD", "page": 161 } + ], + "description": { + "entries": [ + "You touch a living creature that has 0 hit points. The creature becomes stable. This spell has no effect on undead or constructs." + ] + } + }, + { + "name": "Sword Burst", + "school": "conjuration", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "damage_inflict": [ + "force" + ], + "saving_throw": [ + "dexterity" + ], + "range": { + "type": "self", + "amount": 5, + "unit": "feet" + }, + "components": { + "verbal": true, + "somatic": false, + "material": false + }, + "durations": [ + { + "type": "instantaneous" + } + ], + "classes": ["artificer", "sorcerer", "warlock", "wizard"], + "sources": [ + { "source": "SCAG", "page": 143 } + ], + "description": { + "entries": [ + "You create a momentary circle of spectral blades that sweep around you. Each creature within range, other than you, must succeed on a Dexterity saving throw or take {@damage 1d6} force damage.", + "This spell's damage increases by {@damage 1d6} when you reach 5th level ({@damage 2d6}), 11th level ({@damage 3d6}), and 17th level ({@damage 4d6})." + ] + } + }, + { + "name": "Thaumaturgy", + "school": "transmutation", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "range": { + "type": "point", + "amount": 30, + "unit": "feet" + }, + "components": { + "verbal": true, + "somatic": false, + "material": false + }, + "durations": [ + { + "type": "timed", + "amount": 1, + "unit": "minutes" + } + ], + "classes": ["cleric"], + "sources": [ + { "source": "PHB", "page": 282 }, + { "source": "SRD", "page": 162 } + ], + "description": { + "entries": [ + "You manifest a minor wonder, a sign of supernatural power, within range. You create one of the following magical effects within range:", + { + "type": "list", + "items": [ + "Your voice booms up to three times as loud as normal for 1 minutes.", + "You cause flames to flicker, brighten, dim, or change color for 1 minutes.", + "You cause harmless tremors in the ground for 1 minutes.", + "You create an instantaneous sound that originates from a point of your choice within range, such as a rumble of thunder, the cry of a raven, or ominous whispers.", + "You instantaneously cause an unlocked door or window to fly open or slam shut.", + "You alter the appearance of your eyes for 1 minutes." + ] + }, + "If you cast this spell multiple times, you can have up to three of its 1-minutes effects active at a time, and you can dismiss such an effect as an action." + ] + } + }, + { + "name": "Thorn Whip", + "school": "transmutation", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "damage_inflict": [ + "piercing" + ], + "attack_type": "melee", + "range": { + "type": "point", + "amount": 30, + "unit": "feet" + }, + "components": { + "verbal": true, + "somatic": true, + "material": true, + "materials_needed": "the stem of a plant with thorns" + }, + "durations": [ + { + "type": "instantaneous" + } + ], + "classes": ["artificer", "druid"], + "sources": [ + { "source": "PHB", "page": 282 }, + { "source": "SRD", "page": 162 } + ], + "description": { + "entries": [ + "You create a long, vine-like whip covered in thorns that lashes out at your command toward a creature in range. Make a melee spell attack against the target. If the attack hits, the creature takes {@damage 1d6} piercing damage, and if the creature is Large or smaller, you pull the creature up to 10 feet closer to you.", + "The spell's damage increases by {@damage 1d6} when you reach 5th level ({@damage 2d6}), 11th level ({@damage 3d6}), and 17th level ({@damage 4d6})." + ] + } + }, + { + "name": "Thunderclap", + "school": "evocation", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "damage_inflict": [ + "thunder" + ], + "saving_throw": [ + "constitution" + ], + "range": { + "type": "point", + "amount": 5, + "unit": "feet" + }, + "components": { + "verbal": true, + "somatic": false, + "material": false + }, + "durations": [ + { + "type": "instantaneous" + } + ], + "classes": ["artificer", "bard", "druid", "sorcerer", "warlock", "wizard"], + "sources": [ + { "source": "EEPC", "page": 168 } + ], + "description": { + "entries": [ + "You create a burst of thunderous sound that can be heard up to 100 feet away. Each creature within range, other than you, must succeed on a Constitution saving throw or take {@damage 1d6} thunder damage.", + "The spell's damage increases by {@damage 1d6} when you reach 5th level ({@damage 2d6}), 11th level ({@damage 3d6}), and 17th level ({@damage 4d6})." + ] + } + }, + { + "name": "Toll the Dead", + "school": "necromancy", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "damage_inflict": [ + "necrotic" + ], + "saving_throw": [ + "wisdom" + ], + "range": { + "type": "point", + "amount": 60, + "unit": "feet" + }, + "components": { + "verbal": true, + "somatic": true, + "material": false + }, + "durations": [ + { + "type": "instantaneous" + } + ], + "classes": ["cleric", "warlock", "wizard"], + "sources": [ + { "source": "XGE", "page": 169 } + ], + "description": { + "entries": [ + "You point at one creature you can see within range, and the sound of a dolorous bell fills the air around it for a moment. The target must succeed on a Wisdom saving throw or take {@damage 1d8} necrotic damage. If the target is missing any of its hit points, it instead takes {@damage 1d12} necrotic damage.", + "The spell's damage increases by one die when you reach 5th level ({@damage 2d8} or {@damage 2d12}), 11th level ({@damage 3d8} or {@damage 3d12}), and 17th level ({@damage 4d8} or {@damage 4d12})." + ] + } + }, + { + "name": "True Strike", + "school": "divination", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "range": { + "type": "point", + "amount": 30, + "unit": "feet" + }, + "components": { + "verbal": false, + "somatic": true, + "material": false + }, + "durations": [ + { + "type": "concentration", + "amount": 1, + "unit": "round" + } + ], + "classes": ["bard", "sorcerer", "warlock", "wizard"], + "sources": [ + { "source": "PHB", "page": 284 }, + { "source": "SRD", "page": 163 } + ], + "description": { + "entries": [ + "You extend your hand and point a finger at a target in range. Your magic grants you a brief insight into the target's defenses. On your next turn, you gain advantage on your first attack roll against the target, provided that this spell hasn't ended." + ] + } + }, + { + "name": "Vicious Mockery", + "school": "enchantment", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "damage_inflict": [ + "psychic" + ], + "saving_throw": [ + "wisdom" + ], + "range": { + "type": "point", + "amount": 60, + "unit": "feet" + }, + "components": { + "verbal": true, + "somatic": false, + "material": false + }, + "durations": [ + { + "type": "instantaneous" + } + ], + "classes": ["bard"], + "sources": [ + { "source": "PHB", "page": 285 }, + { "source": "SRD", "page": 163 } + ], + "description": { + "entries": [ + "You unleash a string of insults laced with subtle enchantments at a creature you can see within range. If the target can hear you (though it need not understand you), it must succeed on a Wisdom saving throw or take {@damage 1d4} psychic damage and have disadvantage on the next attack roll it makes before the end of its next turn.", + "This spell's damage increases by {@damage 1d4} when you reach 5th level ({@damage 2d4}), 11th level ({@damage 3d4}), and 17th level ({@damage 4d4})." + ] + } + }, + { + "name": "Word of Radiance", + "school": "evocation", + "level": 0, + "ritual": false, + "casting_time": { + "unit": "action", + "amount": 1 + }, + "damage_inflict": [ + "radiant" + ], + "saving_throw": [ + "constitution" + ], + "range": { + "type": "point", + "amount": 5, + "unit": "feet" + }, + "components": { + "verbal": true, + "somatic": false, + "material": true, + "materials_needed": "a holy symbol" + }, + "durations": [ + { + "type": "instantaneous" + } + ], + "classes": ["cleric"], + "sources": [ + { "source": "XGE", "page": 171 } + ], + "description": { + "entries": [ + "You utter a divine word, and burning radiance erupts from you. Each creature of your choice that you can see within range must succeed on a Constitution saving throw or take {@damage 1d6} radiant damage.", + "The spell's damage increases by {@damage 1d6} when you reach 5th level ({@damage 2d6}), 11th level ({@damage 3d6}), and 17th level ({@damage 4d6})." + ] + } + } +] \ No newline at end of file diff --git a/service/data/spells/level_1.json b/service/data/spells/level_1.json new file mode 100644 index 0000000..e69de29 diff --git a/service/docker-compose.yml b/service/docker-compose.yml new file mode 100644 index 0000000..6dde2aa --- /dev/null +++ b/service/docker-compose.yml @@ -0,0 +1,51 @@ +version: '3.8' + +services: + service: + image: siren-service:${SIREN_VERSION:-latest} + container_name: siren-service + build: + context: . + dockerfile: ./Dockerfile + args: + - VERSION=${SIREN_VERSION:-latest} + env_file: + - .env + environment: + DATABASE_HOST: db + DATABASE_PORT: 5432 + SERVICE_HOST: siren + SERVICE_PORT: 5000 + ports: + - ${SERVICE_PORT:-5000}:5000 + depends_on: + - db + networks: + - frontend + - backend + restart: unless-stopped + db: + image: postgres:latest + container_name: siren-db + env_file: + - .env + environment: + POSTGRES_USER: ${DATABASE_USER} + POSTGRES_PASSWORD: ${DATABASE_PASSWORD} + POSTGRES_DB: ${DATABASE_NAME} + volumes: + - db:/var/lib/postgresql/data + - db_logs:/var/log + ports: + - ${DATABASE_PORT:-5432}:5432 + networks: + - backend + restart: unless-stopped + +volumes: + db: + db_logs: + +networks: + frontend: + backend: diff --git a/service/migrations/000000_create_messages/down.sql b/service/migrations/000000_create_messages/down.sql new file mode 100644 index 0000000..be13677 --- /dev/null +++ b/service/migrations/000000_create_messages/down.sql @@ -0,0 +1 @@ +DROP TABLE messages; \ No newline at end of file diff --git a/migrations/create_messages/up.sql b/service/migrations/000000_create_messages/up.sql similarity index 99% rename from migrations/create_messages/up.sql rename to service/migrations/000000_create_messages/up.sql index f5965cf..f876ba8 100644 --- a/migrations/create_messages/up.sql +++ b/service/migrations/000000_create_messages/up.sql @@ -9,4 +9,4 @@ CREATE TABLE IF NOT EXISTS messages ( response TEXT NOT NULL, request_tags TEXT[] NOT NULL, response_tags TEXT[] NOT NULL -) \ No newline at end of file +); \ No newline at end of file diff --git a/service/migrations/000001_create_races/down.sql b/service/migrations/000001_create_races/down.sql new file mode 100644 index 0000000..f075840 --- /dev/null +++ b/service/migrations/000001_create_races/down.sql @@ -0,0 +1 @@ +DROP TABLE races; \ No newline at end of file diff --git a/service/migrations/000001_create_races/up.sql b/service/migrations/000001_create_races/up.sql new file mode 100644 index 0000000..5ec4de7 --- /dev/null +++ b/service/migrations/000001_create_races/up.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS races ( + id INTEGER GENERATED ALWAYS AS IDENTITY, + name TEXT NOT NULL, + size TEXT NOT NULL, + source TEXT NOT NULL, + data JSON NOT NULL +); \ No newline at end of file diff --git a/service/migrations/000002_create_classes/down.sql b/service/migrations/000002_create_classes/down.sql new file mode 100644 index 0000000..288ada8 --- /dev/null +++ b/service/migrations/000002_create_classes/down.sql @@ -0,0 +1 @@ +DROP TABLE classes; \ No newline at end of file diff --git a/service/migrations/000002_create_classes/up.sql b/service/migrations/000002_create_classes/up.sql new file mode 100644 index 0000000..36f79bf --- /dev/null +++ b/service/migrations/000002_create_classes/up.sql @@ -0,0 +1,3 @@ +CREATE TABLE IF NOT EXISTS classes ( + id INTEGER GENERATED ALWAYS AS IDENTITY +); \ No newline at end of file diff --git a/service/migrations/000003_create_feats/down.sql b/service/migrations/000003_create_feats/down.sql new file mode 100644 index 0000000..4afe0e1 --- /dev/null +++ b/service/migrations/000003_create_feats/down.sql @@ -0,0 +1 @@ +DROP TABLE feats; \ No newline at end of file diff --git a/service/migrations/000003_create_feats/up.sql b/service/migrations/000003_create_feats/up.sql new file mode 100644 index 0000000..0193ae4 --- /dev/null +++ b/service/migrations/000003_create_feats/up.sql @@ -0,0 +1,3 @@ +CREATE TABLE IF NOT EXISTS feats ( + id INTEGER GENERATED ALWAYS AS IDENTITY +); \ No newline at end of file diff --git a/service/migrations/000004_create_options_features/down.sql b/service/migrations/000004_create_options_features/down.sql new file mode 100644 index 0000000..0fcca0f --- /dev/null +++ b/service/migrations/000004_create_options_features/down.sql @@ -0,0 +1 @@ +DROP TABLE options_features; \ No newline at end of file diff --git a/service/migrations/000004_create_options_features/up.sql b/service/migrations/000004_create_options_features/up.sql new file mode 100644 index 0000000..d64ba24 --- /dev/null +++ b/service/migrations/000004_create_options_features/up.sql @@ -0,0 +1,3 @@ +CREATE TABLE IF NOT EXISTS options_features ( + id INTEGER GENERATED ALWAYS AS IDENTITY +); \ No newline at end of file diff --git a/service/migrations/000005_create_backgrounds/down.sql b/service/migrations/000005_create_backgrounds/down.sql new file mode 100644 index 0000000..74f4344 --- /dev/null +++ b/service/migrations/000005_create_backgrounds/down.sql @@ -0,0 +1 @@ +DROP TABLE backgrounds; \ No newline at end of file diff --git a/service/migrations/000005_create_backgrounds/up.sql b/service/migrations/000005_create_backgrounds/up.sql new file mode 100644 index 0000000..0d4bb7f --- /dev/null +++ b/service/migrations/000005_create_backgrounds/up.sql @@ -0,0 +1,3 @@ +CREATE TABLE IF NOT EXISTS backgrounds ( + id INTEGER GENERATED ALWAYS AS IDENTITY +); \ No newline at end of file diff --git a/service/migrations/000006_create_items/down.sql b/service/migrations/000006_create_items/down.sql new file mode 100644 index 0000000..9f8e6a3 --- /dev/null +++ b/service/migrations/000006_create_items/down.sql @@ -0,0 +1 @@ +DROP TABLE items; \ No newline at end of file diff --git a/service/migrations/000006_create_items/up.sql b/service/migrations/000006_create_items/up.sql new file mode 100644 index 0000000..f12f9b2 --- /dev/null +++ b/service/migrations/000006_create_items/up.sql @@ -0,0 +1,3 @@ +CREATE TABLE IF NOT EXISTS items ( + id INTEGER GENERATED ALWAYS AS IDENTITY +); \ No newline at end of file diff --git a/service/migrations/000007_create_spells/down.sql b/service/migrations/000007_create_spells/down.sql new file mode 100644 index 0000000..1b09bdf --- /dev/null +++ b/service/migrations/000007_create_spells/down.sql @@ -0,0 +1 @@ +DROP TABLE spells; \ No newline at end of file diff --git a/service/migrations/000007_create_spells/up.sql b/service/migrations/000007_create_spells/up.sql new file mode 100644 index 0000000..0997c05 --- /dev/null +++ b/service/migrations/000007_create_spells/up.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS spells ( + id INTEGER GENERATED ALWAYS AS IDENTITY, + name TEXT NOT NULL, + school TEXT NOT NULL, + level INTEGER NOT NULL, + ritual BOOLEAN DEFAULT FALSE, + concentration BOOLEAN DEFAULT FALSE, + classes TEXT[] NOT NULL, + damage_inflict TEXT[] NOT NULL, + damage_resist TEXT[] NOT NULL, + conditions TEXT[] NOT NULL, + saving_throw TEXT[] NOT NULL, + attack_type TEXT, + data JSONB NOT NULL +); \ No newline at end of file diff --git a/service/migrations/000008_create_conditions/down.sql b/service/migrations/000008_create_conditions/down.sql new file mode 100644 index 0000000..4cf8449 --- /dev/null +++ b/service/migrations/000008_create_conditions/down.sql @@ -0,0 +1 @@ +DROP TABLE conditions; \ No newline at end of file diff --git a/service/migrations/000008_create_conditions/up.sql b/service/migrations/000008_create_conditions/up.sql new file mode 100644 index 0000000..76b5220 --- /dev/null +++ b/service/migrations/000008_create_conditions/up.sql @@ -0,0 +1,3 @@ +CREATE TABLE IF NOT EXISTS conditions ( + id INTEGER GENERATED ALWAYS AS IDENTITY +); \ No newline at end of file diff --git a/service/migrations/000009_create_bestiary/down.sql b/service/migrations/000009_create_bestiary/down.sql new file mode 100644 index 0000000..9bbe034 --- /dev/null +++ b/service/migrations/000009_create_bestiary/down.sql @@ -0,0 +1 @@ +DROP TABLE bestiary; \ No newline at end of file diff --git a/service/migrations/000009_create_bestiary/up.sql b/service/migrations/000009_create_bestiary/up.sql new file mode 100644 index 0000000..a6019df --- /dev/null +++ b/service/migrations/000009_create_bestiary/up.sql @@ -0,0 +1,3 @@ +CREATE TABLE IF NOT EXISTS bestiary ( + id INTEGER GENERATED ALWAYS AS IDENTITY +); \ No newline at end of file diff --git a/service/src/db/backgrounds/mod.rs b/service/src/db/backgrounds/mod.rs new file mode 100644 index 0000000..e69de29 diff --git a/service/src/db/bestiary/mod.rs b/service/src/db/bestiary/mod.rs new file mode 100644 index 0000000..e69de29 diff --git a/service/src/db/classes/mod.rs b/service/src/db/classes/mod.rs new file mode 100644 index 0000000..24e3024 --- /dev/null +++ b/service/src/db/classes/mod.rs @@ -0,0 +1,3 @@ +mod model; + +pub use model::*; \ No newline at end of file diff --git a/service/src/db/classes/model.rs b/service/src/db/classes/model.rs new file mode 100644 index 0000000..7a42411 --- /dev/null +++ b/service/src/db/classes/model.rs @@ -0,0 +1,46 @@ +use std::str::FromStr; +use serde::{Serialize, Deserialize}; + +#[derive(Debug, Serialize, Deserialize)] +pub enum AbilityType { + #[serde(rename = "strength")] + Strength, + #[serde(rename = "dexterity")] + Dexterity, + #[serde(rename = "constitution")] + Constitution, + #[serde(rename = "intelligence")] + Intelligence, + #[serde(rename = "wisdom")] + Wisdom, + #[serde(rename = "charisma")] + Charisma +} + +impl AbilityType { + pub fn to_string(&self) -> String { + match self { + AbilityType::Strength => "Strength".to_string(), + AbilityType::Dexterity => "Dexterity".to_string(), + AbilityType::Constitution => "Constitution".to_string(), + AbilityType::Intelligence => "Intelligence".to_string(), + AbilityType::Wisdom => "Wisdom".to_string(), + AbilityType::Charisma => "Charisma".to_string() + } + } +} + +impl FromStr for AbilityType { + type Err = (); + fn from_str(s: &str) -> Result { + match s { + "Strength" => Ok(AbilityType::Strength), + "Dexterity" => Ok(AbilityType::Dexterity), + "Constitution" => Ok(AbilityType::Constitution), + "Intelligence" => Ok(AbilityType::Intelligence), + "Wisdom" => Ok(AbilityType::Wisdom), + "Charisma" => Ok(AbilityType::Charisma), + _ => Err(()) + } + } +} \ No newline at end of file diff --git a/service/src/db/conditions/mod.rs b/service/src/db/conditions/mod.rs new file mode 100644 index 0000000..e281d3e --- /dev/null +++ b/service/src/db/conditions/mod.rs @@ -0,0 +1,83 @@ +use std::str::FromStr; +use serde::{Deserialize, Serialize}; + + +#[derive(Debug, Serialize, Deserialize)] +pub enum ConditionType { + #[serde(rename = "blinded")] + Blinded, + #[serde(rename = "charmed")] + Charmed, + #[serde(rename = "deafened")] + Deafened, + #[serde(rename = "exhaustion")] + Exhaustion, + #[serde(rename = "frightened")] + Frightened, + #[serde(rename = "grappled")] + Grappled, + #[serde(rename = "incapacitated")] + Incapacitated, + #[serde(rename = "invisible")] + Invisible, + #[serde(rename = "paralyzed")] + Paralyzed, + #[serde(rename = "petrified")] + Petrified, + #[serde(rename = "poisoned")] + Poisoned, + #[serde(rename = "prone")] + Prone, + #[serde(rename = "restrained")] + Restrained, + #[serde(rename = "stunned")] + Stunned, + #[serde(rename = "unconscious")] + Unconscious +} + +impl ConditionType { + pub fn to_string(&self) -> String { + match self { + ConditionType::Blinded => "Blinded".to_string(), + ConditionType::Charmed => "Charmed".to_string(), + ConditionType::Deafened => "Deafened".to_string(), + ConditionType::Exhaustion => "Exhaustion".to_string(), + ConditionType::Frightened => "Frightened".to_string(), + ConditionType::Grappled => "Grappled".to_string(), + ConditionType::Incapacitated => "Incapacitated".to_string(), + ConditionType::Invisible => "Invisible".to_string(), + ConditionType::Paralyzed => "Paralyzed".to_string(), + ConditionType::Petrified => "Petrified".to_string(), + ConditionType::Poisoned => "Poisoned".to_string(), + ConditionType::Prone => "Prone".to_string(), + ConditionType::Restrained => "Restrained".to_string(), + ConditionType::Stunned => "Stunned".to_string(), + ConditionType::Unconscious => "Unconscious".to_string() + } + } +} + +impl FromStr for ConditionType { + type Err = (); + fn from_str(s: &str) -> Result { + match s { + "Blinded" => Ok(ConditionType::Blinded), + "Charmed" => Ok(ConditionType::Charmed), + "Deafened" => Ok(ConditionType::Deafened), + "Exhaustion" => Ok(ConditionType::Exhaustion), + "Frightened" => Ok(ConditionType::Frightened), + "Grappled" => Ok(ConditionType::Grappled), + "Incapacitated" => Ok(ConditionType::Incapacitated), + "Invisible" => Ok(ConditionType::Invisible), + "Paralyzed" => Ok(ConditionType::Paralyzed), + "Petrified" => Ok(ConditionType::Petrified), + "Poisoned" => Ok(ConditionType::Poisoned), + "Prone" => Ok(ConditionType::Prone), + "Restrained" => Ok(ConditionType::Restrained), + "Stunned" => Ok(ConditionType::Stunned), + "Unconscious" => Ok(ConditionType::Unconscious), + _ => Err(()) + } + } +} diff --git a/service/src/db/feats/mod.rs b/service/src/db/feats/mod.rs new file mode 100644 index 0000000..e69de29 diff --git a/service/src/db/items/mod.rs b/service/src/db/items/mod.rs new file mode 100644 index 0000000..e69de29 diff --git a/service/src/db/messages/mod.rs b/service/src/db/messages/mod.rs new file mode 100644 index 0000000..6fbb137 --- /dev/null +++ b/service/src/db/messages/mod.rs @@ -0,0 +1,5 @@ +mod model; +mod routes; + +pub use model::*; +pub use routes::init_routes; diff --git a/service/src/db/messages/model.rs b/service/src/db/messages/model.rs new file mode 100644 index 0000000..a5f9ebb --- /dev/null +++ b/service/src/db/messages/model.rs @@ -0,0 +1,150 @@ +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; +use siren::ServiceError; + +use crate::db::schema::messages::{self}; + +#[derive(Queryable, Selectable, Serialize, Deserialize)] +#[diesel(table_name = messages)] +pub struct QueryMessage { + pub id: String, + pub guild_id: i64, + pub channel_id: i64, + pub user_id: i64, + pub created: i64, + pub model: String, + pub request: String, + pub response: String, + pub request_tags: Vec, + pub response_tags: Vec, +} + +pub struct QueryFilters { + pub by_id: Option, + pub by_guild_id: Option, + pub by_channel_id: Option, + pub by_user_id: Option, + pub by_model: Option, + pub by_request: Option, + pub by_response: Option, + pub by_request_tags: Option>, + pub by_response_tags: Option> +} + +impl Default for QueryFilters { + fn default() -> Self { + QueryFilters { + by_id: None, + by_guild_id: None, + by_channel_id: None, + by_user_id: None, + by_model: None, + by_request: None, + by_response: None, + by_request_tags: None, + by_response_tags: None + } + } +} + +impl QueryMessage { + pub fn get_all(filters: &QueryFilters, limit: i32, page: i32) -> Result, ServiceError> { + let mut conn = crate::db::connection()?; + let mut query = messages::table.limit(limit as i64).order(messages::created.asc()).into_boxed(); + // Limit query to page and limit + let offset = (page - 1) * limit; + query = query.offset(offset as i64); + // Apply filters + if let Some(id) = &filters.by_id { + query = query.filter(messages::id.eq(id)); + } + if let Some(guild_id) = &filters.by_guild_id { + query = query.filter(messages::guild_id.eq(guild_id)); + } + if let Some(channel_id) = &filters.by_channel_id { + query = query.filter(messages::channel_id.eq(channel_id)); + } + if let Some(user_id) = &filters.by_user_id { + query = query.filter(messages::user_id.eq(user_id)); + } + if let Some(model) = &filters.by_model { + query = query.filter(messages::model.eq(model)); + } + if let Some(request) = &filters.by_request { + query = query.filter(messages::request.eq(request)); + } + if let Some(response) = &filters.by_response { + query = query.filter(messages::response.eq(response)); + } + if let Some(request_tags) = &filters.by_request_tags { + query = query.filter(messages::request_tags.eq(request_tags)); + } + if let Some(response_tags) = &filters.by_response_tags { + query = query.filter(messages::response_tags.eq(response_tags)); + } + // Execute query + let messages = query.load::(&mut conn)?; + Ok(messages) + } + + pub fn get_count(fitlers: &QueryFilters) -> Result { + let mut conn = crate::db::connection()?; + let mut query = messages::table.into_boxed(); + // Apply filters + if let Some(id) = &fitlers.by_id { + query = query.filter(messages::id.eq(id)); + } + if let Some(guild_id) = &fitlers.by_guild_id { + query = query.filter(messages::guild_id.eq(guild_id)); + } + if let Some(channel_id) = &fitlers.by_channel_id { + query = query.filter(messages::channel_id.eq(channel_id)); + } + if let Some(user_id) = &fitlers.by_user_id { + query = query.filter(messages::user_id.eq(user_id)); + } + if let Some(model) = &fitlers.by_model { + query = query.filter(messages::model.eq(model)); + } + if let Some(request) = &fitlers.by_request { + query = query.filter(messages::request.eq(request)); + } + if let Some(response) = &fitlers.by_response { + query = query.filter(messages::response.eq(response)); + } + if let Some(request_tags) = &fitlers.by_request_tags { + query = query.filter(messages::request_tags.eq(request_tags)); + } + if let Some(response_tags) = &fitlers.by_response_tags { + query = query.filter(messages::response_tags.eq(response_tags)); + } + // Execute query + let count = query.count().get_result::(&mut conn)?; + Ok(count) + } +} + +#[derive(Insertable, AsChangeset, Serialize, Deserialize)] +#[diesel(table_name = messages)] +pub struct InsertMessage { + pub id: String, + pub guild_id: i64, + pub channel_id: i64, + pub user_id: i64, + pub created: i64, + pub model: String, + pub request: String, + pub response: String, + pub request_tags: Vec, + pub response_tags: Vec, +} + +impl InsertMessage { + pub fn insert(message: Self) -> Result { + let mut conn = crate::db::connection()?; + let message = diesel::insert_into(messages::table) + .values(message) + .get_result(&mut conn)?; + Ok(message) + } +} \ No newline at end of file diff --git a/service/src/db/messages/routes.rs b/service/src/db/messages/routes.rs new file mode 100644 index 0000000..6f1ea21 --- /dev/null +++ b/service/src/db/messages/routes.rs @@ -0,0 +1,80 @@ +use actix_web::{get, post, web, HttpResponse, HttpRequest, ResponseError}; +use log::error; +use serde::{Serialize, Deserialize}; +use siren::{GetResponse, Metadata, ServiceError}; + +use crate::db::messages::{QueryMessage, QueryFilters, InsertMessage}; + +#[derive(Serialize, Deserialize)] +struct GetAllParams { + id: Option, + guild_id: Option, + channel_id: Option, + user_id: Option, + model: Option, + request: Option, + response: Option, + request_tags: Option>, + response_tags: Option>, + limit: Option, + page: Option, +} + +#[get("/messages")] +async fn get_all(req: HttpRequest) -> HttpResponse { + let params = match web::Query::::from_query(req.query_string()) { + Ok(params) => params, + Err(err) => return ResponseError::error_response(&ServiceError { + status: 422, + message: err.to_string() + }) + }; + let mut filters = QueryFilters::default(); + filters.by_id = params.id.clone(); + filters.by_guild_id = params.guild_id; + filters.by_channel_id = params.channel_id; + filters.by_user_id = params.user_id; + filters.by_model = params.model.clone(); + filters.by_request = params.request.clone(); + filters.by_response = params.response.clone(); + filters.by_request_tags = params.request_tags.clone(); + filters.by_response_tags = params.response_tags.clone(); + let limit = params.limit.unwrap_or(100); + let total_count = QueryMessage::get_count(&filters).unwrap(); + let max_page = std::cmp::max((total_count as f64 / limit as f64).ceil() as i32, 1); + let page = std::cmp::min(std::cmp::max(params.page.unwrap_or(1), 1), max_page); + + match QueryMessage::get_all(&filters, limit, page) { + Ok(messages) => { + HttpResponse::Ok().json(GetResponse { + data: messages, + metadata: Some(Metadata { + total: total_count as i32, + limit, + page, + pages: max_page + }) + }) + }, + Err(err) => { + error!("{:?}", err.message); + ResponseError::error_response(&err) + } + } +} + +#[post("/messages")] +async fn create(message: web::Json) -> HttpResponse { + match InsertMessage::insert(message.into_inner()) { + Ok(message) => HttpResponse::Created().json(message), + Err(err) => { + error!("{:?}", err.message); + ResponseError::error_response(&err) + } + } +} + +pub fn init_routes(config: &mut web::ServiceConfig) { + config.service(get_all); + config.service(create); +} \ No newline at end of file diff --git a/service/src/db/mod.rs b/service/src/db/mod.rs new file mode 100644 index 0000000..8325a8c --- /dev/null +++ b/service/src/db/mod.rs @@ -0,0 +1,56 @@ +use diesel::{r2d2::ConnectionManager, PgConnection}; +use siren::ServiceError; +use crate::diesel_migrations::MigrationHarness; +use lazy_static::lazy_static; +use log::{error, info}; +use r2d2; +use std::env; + +pub mod backgrounds; +pub mod bestiary; +pub mod classes; +pub mod conditions; +pub mod feats; +pub mod items; +pub mod messages; +pub mod options; +pub mod races; +pub mod spells; +pub mod users; +pub mod schema; + +type Pool = r2d2::Pool>; +pub type DbConnection = r2d2::PooledConnection>; + +pub const MIGRATIONS: diesel_migrations::EmbeddedMigrations = embed_migrations!(); + +lazy_static! { + static ref POOL: Pool = { + let username = env::var("DATABASE_USER").expect("DATABASE_USERNAME is not set"); + let password = env::var("DATABASE_PASSWORD").expect("DATABASE_PASSWORD is not set"); + let host = env::var("DATABASE_HOST").unwrap_or("localhost".to_string()); + let name = env::var("DATABASE_NAME").expect("DATABASE_NAME is not set"); + let port = env::var("DATABASE_PORT").unwrap_or("5432".to_string()); + let url = format!("postgres://{}:{}@{}:{}/{}", username, password, host, port, name); + let manager = ConnectionManager::::new(url); + Pool::builder().test_on_check_out(true).build(manager).expect("Failed to create db pool") + }; +} + +pub fn init() { + lazy_static::initialize(&POOL); + let mut pool: DbConnection = connection().expect("Failed to get db connection"); + match pool.run_pending_migrations(MIGRATIONS) { + Ok(_) => info!("Database initialized"), + Err(err) => error!("Failed to initialize database; {}", err) + }; +} + +pub fn connection() -> Result { + POOL.get() + .map_err(|e| ServiceError::new(500, format!("Failed getting db connection: {}", e))) +} + +pub fn load_data() { + spells::load_data(); +} diff --git a/service/src/db/options/mod.rs b/service/src/db/options/mod.rs new file mode 100644 index 0000000..e69de29 diff --git a/service/src/db/races/mod.rs b/service/src/db/races/mod.rs new file mode 100644 index 0000000..e69de29 diff --git a/service/src/db/schema.rs b/service/src/db/schema.rs new file mode 100644 index 0000000..a148271 --- /dev/null +++ b/service/src/db/schema.rs @@ -0,0 +1,32 @@ +diesel::table! { + messages (id) { + id -> Text, + guild_id -> BigInt, + channel_id -> BigInt, + user_id -> BigInt, + created -> BigInt, + model -> Text, + request -> Text, + response -> Text, + request_tags -> Array, + response_tags -> Array, + } +} + +diesel::table! { + spells (id) { + id -> Integer, + name -> Text, + school -> Text, + level -> Integer, + ritual -> Bool, + concentration -> Bool, + classes -> Array, + damage_inflict -> Array, + damage_resist -> Array, + conditions -> Array, + saving_throw -> Array, + attack_type -> Nullable, + data -> Jsonb + } +} \ No newline at end of file diff --git a/service/src/db/spells/mod.rs b/service/src/db/spells/mod.rs new file mode 100644 index 0000000..27ba8cf --- /dev/null +++ b/service/src/db/spells/mod.rs @@ -0,0 +1,47 @@ +mod model; +mod routes; +mod types; + +pub use model::*; +pub use types::*; +pub use routes::init_routes; + +pub fn load_data() { + let root_path = std::env::current_dir().unwrap(); + let files = [ + "cantrips.json", "level_1.json", "level_2.json", "level_3.json", "level_4.json", "level_5.json", "level_6.json", "level_7.json", "level_8.json", "level_9.json" + ]; + let mut spells: Vec = vec![]; + for file in files { + let mut data_path = std::path::PathBuf::from(&root_path); + data_path.push(format!("data/spells/{}", file)); + let path = data_path.to_str().unwrap(); + match std::fs::read_to_string(path) { + Ok(data) => { + log::debug!("Loading spells from {}", path); + match serde_json::from_str::(&data) { + Ok(json) => { + match serde_json::from_value::>(json) { + Ok(mut new_spells) => spells.append(&mut new_spells), + Err(err) => log::error!("Failed to parse spells data: {}", err) + } + }, + Err(err) => log::error!("Failed to parse spells data to value: {}", err) + }; + }, + Err(err) => log::error!("Failed to read from {}: {}", file, err) + }; + } + let count = QuerySpell::get_count(&QueryFilters::default()).unwrap(); + if count >= spells.len() as i64 { + log::warn!("Spell data is already loaded"); + return; + } + for spell in spells { + let spell_name = spell.name.clone(); + match InsertSpell::insert(spell.into()) { + Ok(_) => {}, + Err(err) => log::error!("Failed to insert '{}' spell: {}", spell_name, err) + } + } +} diff --git a/service/src/db/spells/model.rs b/service/src/db/spells/model.rs new file mode 100644 index 0000000..1616134 --- /dev/null +++ b/service/src/db/spells/model.rs @@ -0,0 +1,291 @@ +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; +use siren::ServiceError; + +use crate::db::{schema::spells::{self}, classes::AbilityType, conditions::ConditionType}; + +use super::{SchoolType, CastingTime, CastingType, SpellAttackType, SpellDamageType, Range, Area, Components, Duration, Source, Description, DurationType}; + +#[derive(Queryable, QueryableByName)] +#[diesel(table_name = spells)] +pub struct QuerySpell { + pub id: i32, + pub name: String, + pub school: String, + pub level: i32, + pub ritual: bool, + pub concentration: bool, + pub classes: Vec, + pub damage_inflict: Vec, + pub damage_resist: Vec, + pub conditions: Vec, + pub saving_throw: Vec, + pub attack_type: Option, + pub data: serde_json::Value, +} + +#[derive(Debug)] +pub struct QueryFilters { + pub by_name: Option, + pub by_schools: Option>, + pub by_levels: Option>, + pub by_ritual: Option, + pub by_concentration: Option, + pub by_classes: Option>, + pub by_damage_inflict: Option>, + pub by_damage_resist: Option>, + pub by_conditions: Option>, + pub by_saving_throw: Option>, + pub by_attack_type: Option, +} + +impl Default for QueryFilters { + fn default() -> Self { + Self { + by_name: None, + by_schools: None, + by_levels: None, + by_ritual: None, + by_concentration: None, + by_classes: None, + by_damage_inflict: None, + by_damage_resist: None, + by_conditions: None, + by_saving_throw: None, + by_attack_type: None, + } + } +} + +impl QuerySpell { + pub fn get_all(filters: &QueryFilters, limit: i32, page: i32) -> Result, ServiceError> { + let mut conn = crate::db::connection()?; + let mut query = spells::table.limit(limit as i64).into_boxed(); + // Limit query to page and limit + let offset = (page - 1) * limit; + query = query.offset(offset as i64); + if let Some(name) = &filters.by_name { + query = query.filter(spells::name.ilike(format!("%{}%", name))); + } + if let Some(schools) = &filters.by_schools { + query = query.filter(spells::school.eq_any(schools.iter().map(|school| school.to_string()).collect::>())); + } + if let Some(levels) = &filters.by_levels { + query = query.filter(spells::level.eq_any(levels)); + } + if let Some(ritual) = filters.by_ritual { + query = query.filter(spells::ritual.eq(ritual)); + } + if let Some(concentration) = filters.by_concentration { + query = query.filter(spells::concentration.eq(concentration)); + } + if let Some(classes) = &filters.by_classes { + query = query.filter(spells::classes.overlaps_with(classes)); + } + if let Some(damage_inflict) = &filters.by_damage_inflict { + query = query.filter(spells::damage_inflict.overlaps_with(damage_inflict.iter().map(|damage_inflict| damage_inflict.to_string()).collect::>())); + } + if let Some(damage_resist) = &filters.by_damage_resist { + query = query.filter(spells::damage_resist.overlaps_with(damage_resist.iter().map(|damage_resist| damage_resist.to_string()).collect::>())); + } + if let Some(conditions) = &filters.by_conditions { + query = query.filter(spells::conditions.overlaps_with(conditions.iter().map(|condition| condition.to_string()).collect::>())); + } + if let Some(saving_throw) = &filters.by_saving_throw { + query = query.filter(spells::saving_throw.overlaps_with(saving_throw.iter().map(|saving_throw| saving_throw.to_string()).collect::>())); + } + if let Some(attack_type) = &filters.by_attack_type { + query = query.filter(spells::attack_type.eq(attack_type.to_string())); + } + + let spells = query.load::(&mut conn)?; + Ok(spells) + } + + pub fn get_count(filters: &QueryFilters) -> Result { + let mut conn = crate::db::connection()?; + let mut query = spells::table.count().into_boxed(); + if let Some(name) = &filters.by_name { + query = query.filter(spells::name.ilike(format!("%{}%", name))); + } + if let Some(schools) = &filters.by_schools { + query = query.filter(spells::school.eq_any(schools.iter().map(|school| school.to_string()).collect::>())); + } + if let Some(levels) = &filters.by_levels { + query = query.filter(spells::level.eq_any(levels)); + } + if let Some(ritual) = filters.by_ritual { + query = query.filter(spells::ritual.eq(ritual)); + } + if let Some(concentration) = filters.by_concentration { + query = query.filter(spells::concentration.eq(concentration)); + } + if let Some(classes) = &filters.by_classes { + query = query.filter(spells::classes.overlaps_with(classes)); + } + if let Some(damage_inflict) = &filters.by_damage_inflict { + query = query.filter(spells::damage_inflict.overlaps_with(damage_inflict.iter().map(|damage_inflict| damage_inflict.to_string()).collect::>())); + } + if let Some(damage_resist) = &filters.by_damage_resist { + query = query.filter(spells::damage_resist.overlaps_with(damage_resist.iter().map(|damage_resist| damage_resist.to_string()).collect::>())); + } + if let Some(conditions) = &filters.by_conditions { + query = query.filter(spells::conditions.overlaps_with(conditions.iter().map(|condition| condition.to_string()).collect::>())); + } + if let Some(saving_throw) = &filters.by_saving_throw { + query = query.filter(spells::saving_throw.overlaps_with(saving_throw.iter().map(|saving_throw| saving_throw.to_string()).collect::>())); + } + if let Some(attack_type) = &filters.by_attack_type { + query = query.filter(spells::attack_type.eq(attack_type.to_string())); + } + + let count = query.get_result(&mut conn)?; + Ok(count) + } + + pub fn get_by_id(id: i32) -> Result { + let mut conn = crate::db::connection()?; + let spell = spells::table + .filter(spells::id.eq(id)) + .first::(&mut conn)?; + Ok(spell) + } + + pub fn delete(id: i32) -> Result { + let mut conn = crate::db::connection()?; + let spell = diesel::delete(spells::table.filter(spells::id.eq(id))).get_result(&mut conn)?; + Ok(spell) + } +} + +#[derive(Insertable, AsChangeset)] +#[diesel(table_name = spells)] +pub struct InsertSpell { + pub name: String, + pub school: String, + pub level: i32, + pub ritual: bool, + pub concentration: bool, + pub classes: Vec, + pub damage_inflict: Vec, + pub damage_resist: Vec, + pub conditions: Vec, + pub saving_throw: Vec, + pub attack_type: Option, + pub data: serde_json::Value +} + +impl InsertSpell { + pub fn insert(spell: Self) -> Result { + let mut conn = crate::db::connection()?; + let spell = diesel::insert_into(spells::table).values(spell).get_result(&mut conn)?; + Ok(spell) + } + + pub fn update(id: i32, spell: Self) -> Result { + let mut conn = crate::db::connection()?; + let spell = diesel::update(spells::table.filter(spells::id.eq(id))).set(spell).get_result(&mut conn)?; + Ok(spell) + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Spell { + pub name: String, + pub school: SchoolType, + pub level: i32, + pub ritual: bool, + pub casting_time: CastingTime, + #[serde(skip_serializing_if = "Option::is_none")] + pub saving_throw: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub attack_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub damage_inflict: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub damage_resist: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub conditions: Option>, + pub range: Range, + #[serde(skip_serializing_if = "Option::is_none")] + pub area: Option, + pub components: Components, + pub durations: Vec, + pub classes: Vec, + pub sources: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub tags: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option +} + +impl From for Spell { + fn from(query: QuerySpell) -> Self { + return match serde_json::from_value(query.data) { + Ok(data) => data, + Err(err) => { + log::error!("Failed to parse spell: {}", err); + Self { + name: "".to_string(), + school: SchoolType::Abjuration, + level: 0, + ritual: false, + casting_time: CastingTime { amount: 0, casting_type: CastingType::Action }, + saving_throw: None, + attack_type: None, + damage_inflict: None, + damage_resist: None, + conditions: None, + range: Range { range_type: "".to_string(), amount: None, unit: None }, + area: None, + components: Components { verbal: false, somatic: false, material: false, materials_needed: None, materials_cost: None, materials_consumed: None }, + durations: vec![], + classes: vec![], + sources: vec![], + tags: None, + description: None, + } + } + } + } +} + +impl Into for Spell { + fn into(self) -> InsertSpell { + return InsertSpell { + name: self.name.to_string(), + school: self.school.to_string(), + level: self.level, + ritual: self.ritual, + concentration: self.durations.iter().any(|duration| match duration.duration_type { + DurationType::Concentration => true, + _ => false + }), + classes: self.classes.iter().map(|class| class.to_string()).collect::>(), + damage_inflict: match &self.damage_inflict { + Some(damage_inflict) => damage_inflict.iter().map(|damage_inflict| damage_inflict.to_string()).collect(), + None => vec![] + }, + damage_resist: match &self.damage_resist { + Some(damage_resist) => damage_resist.iter().map(|damage_resist| damage_resist.to_string()).collect(), + None => vec![] + }, + conditions: match &self.conditions { + Some(conditions) => conditions.iter().map(|condition| condition.to_string()).collect(), + None => vec![] + }, + saving_throw: match &self.saving_throw { + Some(saving_throw) => saving_throw.iter().map(|saving_throw| saving_throw.to_string()).collect(), + None => vec![] + }, + attack_type: self.attack_type.as_ref().map(|attack_type| attack_type.to_string()), + data: match serde_json::to_value(&self) { + Ok(data) => data, + Err(err) => { + log::error!("Failed to serialize spell: {}", err); + serde_json::Value::Null + } + } + } + } +} diff --git a/service/src/db/spells/routes.rs b/service/src/db/spells/routes.rs new file mode 100644 index 0000000..25ebf11 --- /dev/null +++ b/service/src/db/spells/routes.rs @@ -0,0 +1,178 @@ +use actix_web::{get, post, put, delete, web, HttpResponse, HttpRequest, ResponseError}; +use log::error; +use serde::{Serialize, Deserialize}; +use siren::{GetResponse, Metadata, ServiceError}; + +use crate::db::spells::{QuerySpell, QueryFilters}; + +use super::{Spell, InsertSpell}; + +#[derive(Serialize, Deserialize)] +struct GetAllParams { + name: Option, + schools: Option, + levels: Option, + ritual: Option, + concentration: Option, + classes: Option, + damage_inflict: Option, + damage_resist: Option, + conditions: Option, + saving_throw: Option, + attack_type: Option, + limit: Option, + page: Option, +} + +#[get("/spells")] +async fn get_all(req: HttpRequest) -> HttpResponse { + let params = match web::Query::::from_query(req.query_string()) { + Ok(params) => params, + Err(err) => return ResponseError::error_response(&ServiceError { + status: 422, + message: err.to_string() + }) + }; + let mut filters = QueryFilters::default(); + filters.by_name = params.name.clone(); + filters.by_schools = match ¶ms.schools { + Some(schools) => Some(schools.split(",").map(|s| s.to_string()).collect()), + None => None + }; + filters.by_levels = match ¶ms.levels { + Some(levels) => Some(levels.split(",").map(|s| match s.to_string().parse::() { + Ok(level) => level, + Err(_) => 0 + }).collect()), + None => None + }; + filters.by_ritual = params.ritual; + filters.by_concentration = params.concentration; + filters.by_classes = match ¶ms.classes { + Some(classes) => Some(classes.split(",").map(|s| s.to_string()).collect()), + None => None + }; + filters.by_damage_inflict = match ¶ms.damage_inflict { + Some(damage_inflict) => Some(damage_inflict.split(",").map(|s| s.to_string()).collect()), + None => None + }; + filters.by_damage_resist = match ¶ms.damage_resist { + Some(damage_resist) => Some(damage_resist.split(",").map(|s| s.to_string()).collect()), + None => None + }; + filters.by_conditions = match ¶ms.conditions { + Some(conditions) => Some(conditions.split(",").map(|s| s.to_string()).collect()), + None => None + }; + filters.by_saving_throw = match ¶ms.saving_throw { + Some(saving_throw) => Some(saving_throw.split(",").map(|s| s.to_string()).collect()), + None => None + }; + filters.by_attack_type = match ¶ms.attack_type { + Some(attack_type) => Some(attack_type.split(",").map(|s| s.to_string()).collect()), + None => None + }; + // Limit must be between 1 and 100 + let limit = std::cmp::min(std::cmp::max(params.limit.unwrap_or(20), 1), 100); + let total_count = QuerySpell::get_count(&filters).unwrap(); + let max_page = std::cmp::max((total_count as f64 / limit as f64).ceil() as i32, 1); + // Page must be between 1 and max_page + let page = std::cmp::min(std::cmp::max(params.page.unwrap_or(1), 1), max_page); + + match web::block(move || QuerySpell::get_all(&filters, limit, page)).await.unwrap() { + Ok(spells) => { + let mut response: Vec = Vec::new(); + for spell in spells { + response.push(Spell::from(spell)); + } + HttpResponse::Ok().json(GetResponse { + data: response, + metadata: Some(Metadata { + total: total_count as i32, + limit, + page, + pages: max_page + }) + }) + }, + Err(err) => { + error!("{:?}", err.message); + ResponseError::error_response(&err) + } + } +} + +#[get("/spells/{id}")] +async fn get_by_id(id: web::Path) -> HttpResponse { + let id = match id.parse::() { + Ok(id) => id, + Err(err) => return ResponseError::error_response(&ServiceError { + status: 422, + message: err.to_string() + }) + }; + match web::block(move || QuerySpell::get_by_id(id)).await.unwrap() { + Ok(spell) => HttpResponse::Ok().json(GetResponse { + data: Spell::from(spell), + metadata: None + }), + Err(err) => { + error!("{:?}", err.message); + ResponseError::error_response(&err) + } + } +} + +#[post("/spells")] +async fn create(spell: web::Json) -> HttpResponse { + match InsertSpell::insert(spell.into_inner().into()) { + Ok(spell) => HttpResponse::Created().json(Spell::from(spell)), + Err(err) => { + error!("{:?}", err.message); + ResponseError::error_response(&err) + } + } +} + +#[put("/spells/{id}")] +async fn update(id: web::Path, spell: web::Json) -> HttpResponse { + let id = match id.parse::() { + Ok(id) => id, + Err(err) => return ResponseError::error_response(&ServiceError { + status: 422, + message: err.to_string() + }) + }; + match web::block(move || InsertSpell::update(id, spell.into_inner().into())).await.unwrap() { + Ok(spell) => HttpResponse::Ok().json(Spell::from(spell)), + Err(err) => { + error!("{:?}", err.message); + ResponseError::error_response(&err) + } + } +} + +#[delete("/spells/{id}")] +async fn delete(id: web::Path) -> HttpResponse { + let id = match id.parse::() { + Ok(id) => id, + Err(err) => return ResponseError::error_response(&ServiceError { + status: 422, + message: err.to_string() + }) + }; + match web::block(move || QuerySpell::delete(id)).await.unwrap() { + Ok(spell) => HttpResponse::Ok().json(Spell::from(spell)), + Err(err) => { + error!("{:?}", err.message); + ResponseError::error_response(&err) + } + } +} + +pub fn init_routes(config: &mut web::ServiceConfig) { + config.service(get_all); + config.service(get_by_id); + config.service(create); + config.service(delete); +} \ No newline at end of file diff --git a/service/src/db/spells/types.rs b/service/src/db/spells/types.rs new file mode 100644 index 0000000..6e8f943 --- /dev/null +++ b/service/src/db/spells/types.rs @@ -0,0 +1,377 @@ +use std::str::FromStr; +use serde::{Deserialize, Serialize, ser::SerializeMap}; + +#[derive(Debug, Serialize, Deserialize)] +pub enum SchoolType { + #[serde(rename = "abjuration")] + Abjuration, + #[serde(rename = "conjuration")] + Conjuration, + #[serde(rename = "divination")] + Divination, + #[serde(rename = "enchantment")] + Enchantment, + #[serde(rename = "evocation")] + Evocation, + #[serde(rename = "illusion")] + Illusion, + #[serde(rename = "necromancy")] + Necromancy, + #[serde(rename = "transmutation")] + Transmutation +} + +impl SchoolType { + pub fn to_string(&self) -> String { + match self { + SchoolType::Abjuration => "abjuration".to_string(), + SchoolType::Conjuration => "conjuration".to_string(), + SchoolType::Divination => "divination".to_string(), + SchoolType::Enchantment => "enchantment".to_string(), + SchoolType::Evocation => "evocation".to_string(), + SchoolType::Illusion => "illusion".to_string(), + SchoolType::Necromancy => "necromancy".to_string(), + SchoolType::Transmutation => "transmutation".to_string() + } + } +} + +impl FromStr for SchoolType { + type Err = (); + + fn from_str(s: &str) -> Result { + match s { + "abjuration" => Ok(SchoolType::Abjuration), + "conjuration" => Ok(SchoolType::Conjuration), + "divination" => Ok(SchoolType::Divination), + "enchantment" => Ok(SchoolType::Enchantment), + "evocation" => Ok(SchoolType::Evocation), + "illusion" => Ok(SchoolType::Illusion), + "necromancy" => Ok(SchoolType::Necromancy), + "transmutation" => Ok(SchoolType::Transmutation), + _ => Err(()) + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CastingTime { + pub amount: i32, + #[serde(rename = "unit")] + pub casting_type: CastingType +} + +#[derive(Debug, Serialize, Deserialize)] +pub enum CastingType { + #[serde(rename = "action")] + Action, + #[serde(rename = "bonus")] + BonusAction, + #[serde(rename = "reaction")] + Reaction, + #[serde(rename = "minutes")] + Minutes, + #[serde(rename = "hours")] + Hours +} + +// impl CastingType { +// pub fn to_string(&self) -> String { +// match self { +// CastingType::Action => "action".to_string(), +// CastingType::BonusAction => "bonus".to_string(), +// CastingType::Reaction => "reaction".to_string(), +// CastingType::Minutes => "minutes".to_string(), +// CastingType::Hours => "hours".to_string() +// } +// } +// } + +// impl FromStr for CastingType { +// type Err = (); + +// fn from_str(s: &str) -> Result { +// match s { +// "action" => Ok(CastingType::Action), +// "bonus" => Ok(CastingType::BonusAction), +// "reaction" => Ok(CastingType::Reaction), +// "minutes" => Ok(CastingType::Minutes), +// "hours" => Ok(CastingType::Hours), +// _ => Err(()) +// } +// } +// } + +#[derive(Debug, Serialize, Deserialize)] +pub enum SpellAttackType { + #[serde(rename = "melee")] + Melee, + #[serde(rename = "ranged")] + Ranged, +} + +impl SpellAttackType { + pub fn to_string(&self) -> String { + match self { + SpellAttackType::Melee => "melee".to_string(), + SpellAttackType::Ranged => "ranged".to_string() + } + } +} + +impl FromStr for SpellAttackType { + type Err = (); + + fn from_str(s: &str) -> Result { + match s { + "melee" => Ok(SpellAttackType::Melee), + "ranged" => Ok(SpellAttackType::Ranged), + _ => Err(()) + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub enum SpellDamageType { + #[serde(rename = "acid")] + Acid, + #[serde(rename = "bludgeoning")] + Bludgeoning, + #[serde(rename = "cold")] + Cold, + #[serde(rename = "fire")] + Fire, + #[serde(rename = "force")] + Force, + #[serde(rename = "lightning")] + Lightning, + #[serde(rename = "necrotic")] + Necrotic, + #[serde(rename = "piercing")] + Piercing, + #[serde(rename = "poison")] + Poison, + #[serde(rename = "psychic")] + Psychic, + #[serde(rename = "radiant")] + Radiant, + #[serde(rename = "slashing")] + Slashing, + #[serde(rename = "thunder")] + Thunder +} + +impl SpellDamageType { + pub fn to_string(&self) -> String { + match self { + SpellDamageType::Acid => "acid".to_string(), + SpellDamageType::Bludgeoning => "bludgeoning".to_string(), + SpellDamageType::Cold => "cold".to_string(), + SpellDamageType::Fire => "fire".to_string(), + SpellDamageType::Force => "force".to_string(), + SpellDamageType::Lightning => "lightning".to_string(), + SpellDamageType::Necrotic => "necrotic".to_string(), + SpellDamageType::Piercing => "piercing".to_string(), + SpellDamageType::Poison => "poison".to_string(), + SpellDamageType::Psychic => "psychic".to_string(), + SpellDamageType::Radiant => "radiant".to_string(), + SpellDamageType::Slashing => "slashing".to_string(), + SpellDamageType::Thunder => "thunder".to_string() + } + } +} + +impl FromStr for SpellDamageType { + type Err = (); + + fn from_str(s: &str) -> Result { + match s { + "acid" => Ok(SpellDamageType::Acid), + "bludgeoning" => Ok(SpellDamageType::Bludgeoning), + "cold" => Ok(SpellDamageType::Cold), + "fire" => Ok(SpellDamageType::Fire), + "force" => Ok(SpellDamageType::Force), + "lightning" => Ok(SpellDamageType::Lightning), + "necrotic" => Ok(SpellDamageType::Necrotic), + "piercing" => Ok(SpellDamageType::Piercing), + "poison" => Ok(SpellDamageType::Poison), + "psychic" => Ok(SpellDamageType::Psychic), + "radiant" => Ok(SpellDamageType::Radiant), + "slashing" => Ok(SpellDamageType::Slashing), + "thunder" => Ok(SpellDamageType::Thunder), + _ => Err(()) + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Range { + #[serde(rename = "type")] + pub range_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub amount: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub unit: Option +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Area { + #[serde(rename = "type")] + pub area_type: AreaType, + #[serde(skip_serializing_if = "Option::is_none")] + pub amount: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub unit: Option +} + +#[derive(Debug, Serialize, Deserialize)] +pub enum AreaType { + #[serde(rename = "cone")] + Cone, + #[serde(rename = "cube")] + Cube, + #[serde(rename = "cylinder")] + Cylinder, + #[serde(rename = "line")] + Line, + #[serde(rename = "sphere")] + Sphere +} + +// impl AreaType { +// pub fn to_string(&self) -> String { +// match self { +// AreaType::Cone => "cone".to_string(), +// AreaType::Cube => "cube".to_string(), +// AreaType::Cylinder => "cylinder".to_string(), +// AreaType::Line => "line".to_string(), +// AreaType::Sphere => "sphere".to_string() +// } +// } +// } + +// impl FromStr for AreaType { +// type Err = (); + +// fn from_str(s: &str) -> Result { +// match s { +// "cone" => Ok(AreaType::Cone), +// "cube" => Ok(AreaType::Cube), +// "cylinder" => Ok(AreaType::Cylinder), +// "line" => Ok(AreaType::Line), +// "sphere" => Ok(AreaType::Sphere), +// _ => Err(()) +// } +// } +// } + +#[derive(Debug, Serialize, Deserialize)] +pub struct Duration { + #[serde(rename = "type")] + pub duration_type: DurationType, + #[serde(skip_serializing_if = "Option::is_none")] + pub amount: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub unit: Option +} + +#[derive(Debug, Serialize, Deserialize)] +pub enum DurationType { + #[serde(rename = "concentration")] + Concentration, + #[serde(rename = "instantaneous")] + Instantaneous, + #[serde(rename = "timed")] + Timed, + #[serde(rename = "dispelled")] + UntilDispelled, + #[serde(rename = "special")] + Special +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Source { + pub source: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub page: Option +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Description { + pub entries: Vec +} + +#[derive(Debug)] +pub struct Entry { + pub entry_type: String, + pub items: Vec +} + +impl<'de> Deserialize<'de> for Entry { + fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de> { + let value = serde_json::Value::deserialize(deserializer)?; + match value { + serde_json::Value::String(s) => Ok(Entry { + entry_type: "string".to_string(), + items: vec![s] + }), + serde_json::Value::Object(o) => { + let entry_type = match o.get("type") { + Some(t) => match t.as_str() { + Some(s) => s.to_string(), + None => return Err(serde::de::Error::custom("Invalid entry type")) + }, + None => return Err(serde::de::Error::custom("Missing entry type")) + }; + let items = match o.get("items") { + Some(i) => match i.as_array() { + Some(a) => { + let mut items = Vec::new(); + for item in a { + match item.as_str() { + Some(s) => items.push(s.to_string()), + None => return Err(serde::de::Error::custom("Invalid entry item")) + } + } + items + }, + None => return Err(serde::de::Error::custom("Invalid entry items")) + }, + None => return Err(serde::de::Error::custom("Missing entry items")) + }; + Ok(Entry { + entry_type, + items + }) + }, + _ => Err(serde::de::Error::custom("Invalid entry")) + } + } +} + +impl Serialize for Entry { + fn serialize(&self, serializer: S) -> Result where S: serde::Serializer { + match self.entry_type.as_str() { + "string" => serializer.serialize_str(&self.items[0]), + _ => { + let mut map = serializer.serialize_map(Some(2))?; + map.serialize_entry("type", &self.entry_type)?; + map.serialize_entry("items", &self.items)?; + map.end() + } + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Components { + pub verbal: bool, + pub somatic: bool, + pub material: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub materials_needed: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub materials_cost: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub materials_consumed: Option +} \ No newline at end of file diff --git a/service/src/db/users/mod.rs b/service/src/db/users/mod.rs new file mode 100644 index 0000000..4a7ebf6 --- /dev/null +++ b/service/src/db/users/mod.rs @@ -0,0 +1,3 @@ +mod model; + +pub use model::*; diff --git a/service/src/db/users/model.rs b/service/src/db/users/model.rs new file mode 100644 index 0000000..5b2f343 --- /dev/null +++ b/service/src/db/users/model.rs @@ -0,0 +1,3 @@ +pub struct User { + pub id: i32 +} diff --git a/service/src/lib.rs b/service/src/lib.rs new file mode 100644 index 0000000..f1d97fa --- /dev/null +++ b/service/src/lib.rs @@ -0,0 +1,98 @@ +use actix_web::{ResponseError, HttpResponse}; +use diesel::result::Error as DieselError; +use reqwest::StatusCode; +use serde::{Serialize, Deserialize}; +use std::fmt; + +#[derive(Serialize, Deserialize)] +pub struct Message { + pub id: String, + pub guild_id: i64, + pub channel_id: i64, + pub user_id: i64, + pub created: i64, + pub model: String, + pub request: String, + pub response: String, + pub request_tags: Vec, + pub response_tags: Vec, +} + +#[derive(Serialize, Deserialize)] +pub struct GetResponse { + pub data: T, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option +} + +#[derive(Serialize, Deserialize)] +pub struct Metadata { + pub total: i32, + pub limit: i32, + pub page: i32, + pub pages: i32 +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct ServiceError { + pub status: u16, + pub message: String, +} + +impl ServiceError { + pub fn new(error_status_code: u16, error_message: String) -> ServiceError { + ServiceError { + status: error_status_code, + message: error_message, + } + } +} + +impl fmt::Display for ServiceError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str(self.message.as_str()) + } +} + +impl From for ServiceError { + fn from(error: DieselError) -> ServiceError { + match error { + DieselError::DatabaseError(_, err) => ServiceError::new(409, err.message().to_string()), + DieselError::NotFound => { + ServiceError::new(404, "The record was not found".to_string()) + }, + DieselError::SerializationError(err) => { + ServiceError::new(422, err.to_string()) + }, + err => ServiceError::new(500, format!("Unknown database error: {}", err)), + } + } +} + +impl From for ServiceError { + fn from(error: reqwest::Error) -> ServiceError { + ServiceError::new(500, format!("Unknown reqwest error: {}", error)) + } +} + +impl From for ServiceError { + fn from(error: serde_json::Error) -> ServiceError { + ServiceError::new(500, format!("Unknown serde_json error: {}", error)) + } +} + +impl ResponseError for ServiceError { + fn error_response(&self) -> HttpResponse { + let status_code = match StatusCode::from_u16(self.status) { + Ok(status_code) => status_code, + Err(_) => StatusCode::INTERNAL_SERVER_ERROR, + }; + + let error_message = match status_code.as_u16() < 500 { + true => self.message.clone(), + false => "Internal server error".to_string(), + }; + + HttpResponse::build(status_code).json(serde_json::json!({ "status": status_code.as_u16(), "message": error_message })) + } +} \ No newline at end of file diff --git a/service/src/main.rs b/service/src/main.rs new file mode 100644 index 0000000..2077335 --- /dev/null +++ b/service/src/main.rs @@ -0,0 +1,41 @@ +extern crate diesel; +#[macro_use] +extern crate diesel_migrations; + +use std::env; + +use actix_web::{HttpServer, App}; + +use dotenv::dotenv; +use log::{error, info}; + +mod db; + +#[actix_web::main] +async fn main() -> std::io::Result<()> { + dotenv().ok(); + env_logger::init_from_env(env_logger::Env::default().filter_or("RUST_LOG", "warn,siren=info")); + db::init(); + db::load_data(); + + let host = env::var("SERVICE_HOST").unwrap_or("localhost".to_string()); + let port = env::var("SERVICE_PORT").unwrap_or("5000".to_string()); + + match HttpServer::new(|| { + App::new() + .configure(db::messages::init_routes) + .configure(db::spells::init_routes) + }) + .bind(format!("{}:{}", host, port)) { + Ok(b) => { + info!("Binding server to {}:{}", host, port); + b + }, + Err(err) => { + error!("Could not bind server: {}", err); + return Err(err); + } + } + .run() + .await +} diff --git a/src/database/mod.rs b/src/database/mod.rs deleted file mode 100644 index 1673f85..0000000 --- a/src/database/mod.rs +++ /dev/null @@ -1,61 +0,0 @@ -use std::env; -// use std::path::Path; - -use diesel::RunQueryDsl; -use diesel::r2d2::{Pool, ConnectionManager}; -use diesel::pg::PgConnection; -use log::{error, info}; - -pub mod models; -pub mod schema; - -pub fn run_migrations(pool: &Pool>) { - let mut connection = pool.get().unwrap(); - if let Err(err) = diesel::sql_query("CREATE TABLE IF NOT EXISTS messages ( - id TEXT PRIMARY KEY NOT NULL, - guild_id BIGINT NOT NULL, - channel_id BIGINT NOT NULL, - user_id BIGINT NOT NULL, - created BIGINT NOT NULL, - model TEXT NOT NULL, - request TEXT NOT NULL, - response TEXT NOT NULL, - request_tags TEXT[] NOT NULL, - response_tags TEXT[] NOT NULL - )").execute(&mut connection) { - error!("Could not create messages table: {}", err); - } else { - info!("Successfully created messages table"); - } - // let migrations_dir = Path::new("./migrations"); - // let migrations = std::fs::read_dir(&migrations_dir).unwrap(); - - // for migration in migrations { - // if migration.as_ref().unwrap().file_type().unwrap().is_dir() { - // let migration_paths = std::fs::read_dir(&migration.unwrap().path()).unwrap(); - - // for migration_path in migration_paths { - // if migration_path.as_ref().unwrap().file_name().eq_ignore_ascii_case("up.sql") { - // let path = &migration_path.unwrap().path(); - // let contents = std::fs::read_to_string(path).expect("Unable to read from file"); - // if let Err(err) = diesel::sql_query(&contents).execute(&mut connection) { - // error!("Could not run migration: {}", err); - // } else { - // info!("Successfully ran migration: {}", path.display()); - // } - // } - // } - // } - // } -} - -pub fn establish_connection() -> Pool> { - let database_user = env::var("POSTGRES_USER").expect("Expected a user in the environment"); - let database_password = env::var("POSTGRES_PASSWORD").expect("Expected a password in the environment"); - let database_name = env::var("POSTGRES_DB").expect("Expected a database name in the environment"); - let database_host = env::var("POSTGRES_HOST").unwrap_or("localhost".to_string()); - - let database_url = format!("postgres://{}:{}@{}/{}", database_user, database_password, database_host, database_name); - let manager = ConnectionManager::::new(database_url); - Pool::builder().build(manager).expect("Failed to create pool.") -} \ No newline at end of file diff --git a/src/database/models.rs b/src/database/models.rs deleted file mode 100644 index 93d9bab..0000000 --- a/src/database/models.rs +++ /dev/null @@ -1,33 +0,0 @@ -use diesel::prelude::*; - -use super::schema::messages; - -#[derive(Queryable, Selectable)] -#[diesel(table_name = messages)] -pub struct MessageDB { - pub id: String, - pub guild_id: i64, - pub channel_id: i64, - pub user_id: i64, - pub created: i64, - pub model: String, - pub request: String, - pub response: String, - pub request_tags: Vec, - pub response_tags: Vec, -} - -#[derive(Insertable)] -#[diesel(table_name = messages)] -pub struct NewMessageDB<'a> { - pub id: &'a str, - pub guild_id: i64, - pub channel_id: i64, - pub user_id: i64, - pub created: i64, - pub model: &'a str, - pub request: &'a str, - pub response: &'a str, - pub request_tags: Vec<&'a str>, - pub response_tags: Vec<&'a str>, -} \ No newline at end of file diff --git a/src/database/schema.rs b/src/database/schema.rs deleted file mode 100644 index 825d574..0000000 --- a/src/database/schema.rs +++ /dev/null @@ -1,14 +0,0 @@ -diesel::table! { - messages (id) { - id -> Text, - guild_id -> BigInt, - channel_id -> BigInt, - user_id -> BigInt, - created -> BigInt, - model -> Text, - request -> Text, - response -> Text, - request_tags -> Array, - response_tags -> Array, - } -} \ No newline at end of file