Merge pull request 'Implement dice rolls' (#1) from feat/simple-rolls into develop

Reviewed-on: #1
This commit is contained in:
Lucien Cartier-Tilet 2025-09-02 16:26:27 +00:00
commit 1e2918db8f
13 changed files with 3067 additions and 2 deletions

View File

@ -0,0 +1 @@
DISCORD_TOKEN=changeme

2648
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -4,3 +4,11 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
bytecount = "0.6.9"
color-eyre = "0.6.5"
dotenvy = "0.15.7"
poise = "0.6.1"
rand = "0.9.2"
tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] }
tracing = "0.1.41"
tracing-subscriber = "0.3.20"

96
flake.lock generated Normal file
View File

@ -0,0 +1,96 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1756542300,
"narHash": "sha256-tlOn88coG5fzdyqz6R93SQL5Gpq+m/DsWpekNFhqPQk=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "d7600c775f877cd87b4f5a831c28aa94137377aa",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1744536153,
"narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1756780571,
"narHash": "sha256-xX0B7Sgx3OQvf6anaNW0vXyYDXbchSx2mnT8rqAPbWA=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "2c18db2acc837a71146ed2d6dae27bf03e3b7a4b",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

40
src/discord/mod.rs Normal file
View File

@ -0,0 +1,40 @@
use color_eyre::eyre::{Error, Result};
use poise::serenity_prelude::{self as serenity, FullEvent};
use tracing::info;
mod roll_dice;
mod source;
type Context<'a> = poise::Context<'a, (), Error>;
fn event_handler(_ctx: serenity::Context, event: &FullEvent) {
if let FullEvent::Ready { data_about_bot } = event {
info!("Logged in as {}", data_about_bot.user.name);
}
}
pub async fn make_bot() -> Result<serenity::Client> {
let token = std::env::var("DISCORD_TOKEN").expect("DISCORD_TOKEN environment variable is missing");
let intents = serenity::GatewayIntents::non_privileged();
let framework = poise::Framework::<(), Error>::builder()
.options(poise::FrameworkOptions {
commands: vec![roll_dice::roll(), source::source()],
event_handler: |ctx, event, _framework: poise::FrameworkContext<'_, (), _>, _data| {
Box::pin(async move {
event_handler(ctx.clone(), event);
Ok(())
})
},
..Default::default()
})
.setup(|ctx, _ready, framework| {
Box::pin(async move {
poise::builtins::register_globally(ctx, &framework.options().commands).await?;
Ok(())
})
})
.build();
Ok(serenity::ClientBuilder::new(token, intents)
.framework(framework)
.await?)
}

View File

@ -0,0 +1,41 @@
use color_eyre::eyre::Error;
use std::str::FromStr;
#[derive(Debug, PartialEq, Eq)]
pub enum Advantage {
None,
Beni,
Maudit,
}
impl FromStr for Advantage {
type Err = color_eyre::eyre::Report;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s {
"aucun" => Ok(Self::None),
"béni" => Ok(Self::Beni),
"maudit" => Ok(Self::Maudit),
other => Err(Error::msg(format!("Could not parse {other} into an Advantage enum"))),
}
}
}
impl TryFrom<Option<String>> for Advantage {
type Error = color_eyre::eyre::Report;
fn try_from(value: Option<String>) -> std::result::Result<Self, Self::Error> {
value.map_or_else(|| Ok(Self::None), |str| Self::from_str(&str))
}
}
impl std::fmt::Display for Advantage {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
let str = match self {
Self::None => "aucun".to_string(),
Self::Beni => "béni".to_string(),
Self::Maudit => "maudit".to_string(),
};
write!(f, "{str}")
}
}

View File

@ -0,0 +1,45 @@
mod advantage;
mod roll;
mod roll_result;
mod success;
use advantage::Advantage;
use roll::Roll;
use roll_result::RollResult;
use success::Success;
use super::Context;
use color_eyre::eyre::Result;
use tracing::info;
async fn advantage_autocomplete(_ctx: Context<'_>, _: &str) -> impl Iterator<Item = String> {
[Advantage::None, Advantage::Beni, Advantage::Maudit]
.iter()
.map(std::string::ToString::to_string)
.collect::<Vec<String>>()
.into_iter()
}
#[poise::command(slash_command)]
pub async fn roll(
ctx: Context<'_>,
#[description = "Seuil de réussite"]
#[min = 0]
#[max = 100]
sr: u8,
#[description = "Maîtrise"]
#[min = 0]
#[max = 6]
mastery: u8,
#[description = "Avantage"]
#[autocomplete = "advantage_autocomplete"]
advantage: Option<String>,
) -> Result<()> {
let advantage = Advantage::try_from(advantage)?;
let roll = Roll::new(sr, mastery, advantage);
info!("Received roll {roll:?}");
let result: RollResult = roll.into();
info!("Result: {result:?}");
ctx.say(result.to_string()).await?;
Ok(())
}

View File

@ -0,0 +1,14 @@
use super::Advantage;
#[derive(Debug)]
pub struct Roll {
pub sr: u8,
pub mastery: u8,
pub advantage: Advantage,
}
impl Roll {
pub const fn new(sr: u8, mastery: u8, advantage: Advantage) -> Self {
Self { sr, mastery, advantage }
}
}

View File

@ -0,0 +1,73 @@
use super::{Advantage, Roll, Success};
fn roll_main_dice() -> u8 {
rand::random_range(0..=11)
}
fn roll_mastery_dice() -> u8 {
rand::random_range(1..=6)
}
#[derive(Debug)]
pub struct RollResult {
sr: u8,
main_dice: u8,
all_main_dice: Vec<u8>,
mastery: Vec<u8>,
result: u8,
success: Success,
}
impl RollResult {
pub fn main_dice_result(main_dice: &[u8], advantage: &Advantage) -> u8 {
match advantage {
Advantage::None => *main_dice.first().unwrap(),
Advantage::Beni => *main_dice.iter().max().unwrap_or(&0),
Advantage::Maudit => *main_dice.iter().min().unwrap_or(&0),
}
}
fn compute_result(main_dice: &[u8], mastery: &[u8], advantage: &Advantage) -> u8 {
let main_dice = Self::main_dice_result(main_dice, advantage);
main_dice + mastery.iter().sum::<u8>()
}
}
impl From<Roll> for RollResult {
fn from(value: Roll) -> Self {
let all_main_dice: Vec<u8> = if value.advantage == Advantage::None {
vec![roll_main_dice()]
} else {
(0..2).map(|_| roll_mastery_dice()).collect()
};
let mastery: Vec<u8> = std::iter::repeat_with(roll_mastery_dice)
.take(value.mastery.into())
.collect();
let sr = value.sr;
let result = Self::compute_result(&all_main_dice, &mastery, &value.advantage);
let main_dice = Self::main_dice_result(&all_main_dice, &value.advantage);
let success = Success::new(main_dice, result, sr, &mastery);
Self {
sr,
main_dice,
all_main_dice,
mastery,
result,
success,
}
}
}
impl std::fmt::Display for RollResult {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
let result = format!(
"Seuil de réussite : {}
(s) du destin: {} ({:?})
(s) de maîtrise: {:?}
Résultat : {}
Réussite : {}",
self.sr, self.main_dice, self.all_main_dice, self.mastery, self.result, self.success
);
write!(f, "{result}")
}
}

View File

@ -0,0 +1,59 @@
#[derive(Debug)]
pub enum Success {
Failure,
Success(SuccessLevel),
}
impl Success {
pub fn new(main_dice: u8, result: u8, sr: u8, mastery: &[u8]) -> Self {
let success_level = SuccessLevel::from(mastery);
match main_dice {
11 => Self::Success(success_level),
_ => {
if result >= sr {
Self::Success(success_level)
} else {
Self::Failure
}
}
}
}
}
impl std::fmt::Display for Success {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
let str = match self {
Self::Failure => "échec".to_owned(),
Self::Success(success_level) => success_level.to_string(),
};
write!(f, "{str}")
}
}
#[derive(Debug)]
pub enum SuccessLevel {
Normal,
Great,
Incredible,
}
impl From<&[u8]> for SuccessLevel {
fn from(value: &[u8]) -> Self {
match bytecount::count(value, 6) {
0 => Self::Normal,
1 => Self::Great,
_ => Self::Incredible,
}
}
}
impl std::fmt::Display for SuccessLevel {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
let level = match self {
Self::Normal => "réussite normale",
Self::Great => "grande réussite",
Self::Incredible => "très grande réussite",
};
write!(f, "{level}")
}
}

10
src/discord/source.rs Normal file
View File

@ -0,0 +1,10 @@
use color_eyre::eyre::Result;
use super::Context;
#[poise::command(slash_command)]
pub async fn source(ctx: Context<'_>) -> Result<()> {
let message = "Mon code source se situe [là](https://labs.phundrak.com/phundrak/roll-one-ring)";
ctx.say(message).await?;
Ok(())
}

View File

@ -1,3 +1,26 @@
fn main() { #![deny(clippy::all, clippy::pedantic, clippy::nursery)]
println!("Hello, world!"); #![warn(missing_docs)]
#![allow(
clippy::module_name_repetitions,
clippy::redundant_pub_crate,
clippy::enum_variant_names
)]
#![allow(clippy::unused_async)]
mod discord;
mod utils;
use color_eyre::Result;
use tracing::info;
#[tokio::main]
async fn main() -> Result<()> {
utils::setup_logging();
info!("Setting up color_eyre...");
color_eyre::install()?;
info!("Reading dotenv...");
let _ = dotenvy::dotenv().map_err(|_| info!("No dotenv file found, skipping."));
let mut bot = discord::make_bot().await?;
bot.start().await?;
Ok(())
} }

7
src/utils.rs Normal file
View File

@ -0,0 +1,7 @@
use tracing::Level;
use tracing_subscriber::FmtSubscriber;
pub fn setup_logging() {
let subscriber = FmtSubscriber::builder().with_max_level(Level::INFO).finish();
tracing::subscriber::set_global_default(subscriber).expect("Setting default logging subscriber failed");
}