diff --git a/src/commands/game/catsay.rs b/src/commands/game/catsay.rs new file mode 100644 index 0000000..22a81e2 --- /dev/null +++ b/src/commands/game/catsay.rs @@ -0,0 +1,45 @@ +use serenity::builder::CreateEmbed; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::common::{send_embed, theme_color, truncate_text}; + +pub async fn handle_catsay(ctx: &Context, msg: &Message, args: &[&str]) { + if args.is_empty() { + let embed = CreateEmbed::new() + .title("Catsay") + .description("Utilise `+catsay `.") + .color(0xED4245); + send_embed(ctx, msg, embed).await; + return; + } + + let text = truncate_text(&args.join(" "), 120); + let bubble = format!("< {} >", text); + let cat = format!("{}\n \\\n \\\n /\\_/\\\n ( o.o )\n > ^ <", bubble); + + let embed = CreateEmbed::new() + .title("Catsay") + .description(format!("```\n{}\n```", cat)) + .color(theme_color(ctx).await); + + send_embed(ctx, msg, embed).await; +} + +pub struct CatsayCommand; +pub static COMMAND_DESCRIPTOR: CatsayCommand = CatsayCommand; + +impl crate::commands::command_contract::CommandSpec for CatsayCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + name: "catsay", + category: "game", + params: "", + description: "Faire parler les chat.", + examples: &["+catsay Bonjour"], + default_aliases: &["meow"], + allow_in_dm: true, + default_permission: 0, + } + } +} diff --git a/src/commands/game/christmas.rs b/src/commands/game/christmas.rs new file mode 100644 index 0000000..6b5ae52 --- /dev/null +++ b/src/commands/game/christmas.rs @@ -0,0 +1,44 @@ +use chrono::{Datelike, NaiveDate, Utc}; +use serenity::builder::CreateEmbed; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::common::{send_embed, theme_color}; + +pub async fn handle_christmas(ctx: &Context, msg: &Message, _args: &[&str]) { + let today = Utc::now().date_naive(); + let mut year = today.year(); + let mut target = NaiveDate::from_ymd_opt(year, 12, 25).unwrap_or(today); + + if today > target { + year += 1; + target = NaiveDate::from_ymd_opt(year, 12, 25).unwrap_or(today); + } + + let days = (target - today).num_days(); + + let embed = CreateEmbed::new() + .title("Christmas") + .description(format!("Il reste **{}** jour(s) avant Noel.", days)) + .color(theme_color(ctx).await); + + send_embed(ctx, msg, embed).await; +} + +pub struct ChristmasCommand; +pub static COMMAND_DESCRIPTOR: ChristmasCommand = ChristmasCommand; + +impl crate::commands::command_contract::CommandSpec for ChristmasCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + name: "christmas", + category: "game", + params: "aucun", + description: "Calcule le nombre de jours jusqu'a Noel.", + examples: &["+christmas"], + default_aliases: &["xmas"], + allow_in_dm: true, + default_permission: 0, + } + } +} diff --git a/src/commands/game/claque.rs b/src/commands/game/claque.rs new file mode 100644 index 0000000..9a17f88 --- /dev/null +++ b/src/commands/game/claque.rs @@ -0,0 +1,51 @@ +use serenity::builder::CreateEmbed; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::common::{mention_user, send_embed, theme_color}; + +pub async fn handle_claque(ctx: &Context, msg: &Message, args: &[&str]) { + let target = msg + .mentions + .first() + .map(|user| mention_user(user.id)) + .or_else(|| { + args.first().map(|raw| { + if raw.starts_with("<@") { + raw.to_string() + } else { + format!("**{}**", raw) + } + }) + }) + .unwrap_or_else(|| "le vide".to_string()); + + let embed = CreateEmbed::new() + .title("Claque") + .description(format!( + "{} met une claque a {}.", + mention_user(msg.author.id), + target + )) + .color(theme_color(ctx).await); + + send_embed(ctx, msg, embed).await; +} + +pub struct ClaqueCommand; +pub static COMMAND_DESCRIPTOR: ClaqueCommand = ClaqueCommand; + +impl crate::commands::command_contract::CommandSpec for ClaqueCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + name: "claque", + category: "game", + params: "[@user]", + description: "Fait une claque a un utilisateur mentionne ou a un utilisateur.", + examples: &["+claque", "+claque @Pseudo"], + default_aliases: &["slap"], + allow_in_dm: true, + default_permission: 0, + } + } +} diff --git a/src/commands/game/demineur.rs b/src/commands/game/demineur.rs new file mode 100644 index 0000000..5be9281 --- /dev/null +++ b/src/commands/game/demineur.rs @@ -0,0 +1,97 @@ +use rand::seq::SliceRandom; +use serenity::builder::CreateEmbed; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::common::{send_embed, theme_color}; + +const SIZE: usize = 5; +const MINES: usize = 5; + +pub async fn handle_demineur(ctx: &Context, msg: &Message, _args: &[&str]) { + let mut mine_positions = (0..(SIZE * SIZE)).collect::>(); + { + let mut rng = rand::thread_rng(); + mine_positions.shuffle(&mut rng); + } + mine_positions.truncate(MINES); + + let mut board = vec![vec![0u8; SIZE]; SIZE]; + for index in &mine_positions { + let row = index / SIZE; + let col = index % SIZE; + board[row][col] = 9; + } + + for row in 0..SIZE { + for col in 0..SIZE { + if board[row][col] == 9 { + continue; + } + let mut count = 0u8; + for dr in -1isize..=1 { + for dc in -1isize..=1 { + if dr == 0 && dc == 0 { + continue; + } + let nr = row as isize + dr; + let nc = col as isize + dc; + if nr >= 0 + && nr < SIZE as isize + && nc >= 0 + && nc < SIZE as isize + && board[nr as usize][nc as usize] == 9 + { + count += 1; + } + } + } + board[row][col] = count; + } + } + + let rendered = board + .iter() + .map(|line| { + line.iter() + .map(|cell| { + if *cell == 9 { + "||*||".to_string() + } else { + format!("||{}||", cell) + } + }) + .collect::>() + .join(" ") + }) + .collect::>() + .join("\n"); + + let embed = CreateEmbed::new() + .title("Demineur") + .description(format!( + "Plateau genere. Clique les spoilers pour reveler les cases.\n\n{}", + rendered + )) + .color(theme_color(ctx).await); + + send_embed(ctx, msg, embed).await; +} + +pub struct DemineurCommand; +pub static COMMAND_DESCRIPTOR: DemineurCommand = DemineurCommand; + +impl crate::commands::command_contract::CommandSpec for DemineurCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + name: "demineur", + category: "game", + params: "aucun", + description: "Jouer a un jeu demineur.", + examples: &["+demineur"], + default_aliases: &["mine"], + allow_in_dm: true, + default_permission: 0, + } + } +} diff --git a/src/commands/game/eightball.rs b/src/commands/game/eightball.rs new file mode 100644 index 0000000..1496308 --- /dev/null +++ b/src/commands/game/eightball.rs @@ -0,0 +1,60 @@ +use rand::seq::SliceRandom; +use serenity::builder::CreateEmbed; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::common::{send_embed, theme_color}; + +pub async fn handle_eightball(ctx: &Context, msg: &Message, args: &[&str]) { + if args.is_empty() { + let embed = CreateEmbed::new() + .title("8ball") + .description("Pose ta question avec `+8ball `.") + .color(0xED4245); + send_embed(ctx, msg, embed).await; + return; + } + + let answers = [ + "Oui, clairement.", + "Probablement.", + "Je ne pense pas.", + "Reessaye plus tard.", + "C est certain.", + "Impossible a dire pour le moment.", + ]; + + let answer = { + let mut rng = rand::thread_rng(); + answers + .choose(&mut rng) + .copied() + .unwrap_or("Reessaye plus tard.") + }; + + let question = args.join(" "); + let embed = CreateEmbed::new() + .title("8ball") + .description(format!("Question: {}\nReponse: **{}**", question, answer)) + .color(theme_color(ctx).await); + + send_embed(ctx, msg, embed).await; +} + +pub struct EightballCommand; +pub static COMMAND_DESCRIPTOR: EightballCommand = EightballCommand; + +impl crate::commands::command_contract::CommandSpec for EightballCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + name: "8ball", + category: "game", + params: "", + description: "Posez une question a la boule magique 8.", + examples: &["+8ball Vais-je gagner ?"], + default_aliases: &["magic8"], + allow_in_dm: true, + default_permission: 0, + } + } +} diff --git a/src/commands/game/epicgamer.rs b/src/commands/game/epicgamer.rs new file mode 100644 index 0000000..34a8c79 --- /dev/null +++ b/src/commands/game/epicgamer.rs @@ -0,0 +1,51 @@ +use rand::Rng; +use serenity::builder::CreateEmbed; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::common::{send_embed, theme_color}; + +pub async fn handle_epicgamer(ctx: &Context, msg: &Message, _args: &[&str]) { + let percent = { + let mut rng = rand::thread_rng(); + rng.gen_range(0..=100) + }; + + let rank = if percent >= 90 { + "Legendary" + } else if percent >= 70 { + "Epic" + } else if percent >= 40 { + "Casual" + } else { + "Noob" + }; + + let embed = CreateEmbed::new() + .title("Epic Gamer") + .description(format!( + "Ton pourcentage de gamer epique est **{}%**.\nRang: **{}**.", + percent, rank + )) + .color(theme_color(ctx).await); + + send_embed(ctx, msg, embed).await; +} + +pub struct EpicgamerCommand; +pub static COMMAND_DESCRIPTOR: EpicgamerCommand = EpicgamerCommand; + +impl crate::commands::command_contract::CommandSpec for EpicgamerCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + name: "epicgamer", + category: "game", + params: "aucun", + description: "Evaluez votre pourcentage de gamer epique.", + examples: &["+epicgamer"], + default_aliases: &["gamer"], + allow_in_dm: true, + default_permission: 0, + } + } +} diff --git a/src/commands/game/fasttype.rs b/src/commands/game/fasttype.rs new file mode 100644 index 0000000..0826c8b --- /dev/null +++ b/src/commands/game/fasttype.rs @@ -0,0 +1,51 @@ +use rand::seq::SliceRandom; +use serenity::builder::CreateEmbed; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::common::{send_embed, theme_color}; + +pub async fn handle_fasttype(ctx: &Context, msg: &Message, _args: &[&str]) { + let challenges = [ + "shadow bot est rapide", + "rust rend le bot solide", + "je tape plus vite que mon ombre", + "discord et serenite", + ]; + + let sentence = { + let mut rng = rand::thread_rng(); + challenges + .choose(&mut rng) + .copied() + .unwrap_or("shadow bot est rapide") + }; + + let embed = CreateEmbed::new() + .title("FastType") + .description(format!( + "Premier a retaper cette phrase gagne:\n\n`{}`", + sentence + )) + .color(theme_color(ctx).await); + + send_embed(ctx, msg, embed).await; +} + +pub struct FasttypeCommand; +pub static COMMAND_DESCRIPTOR: FasttypeCommand = FasttypeCommand; + +impl crate::commands::command_contract::CommandSpec for FasttypeCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + name: "fasttype", + category: "game", + params: "aucun", + description: "Jouer a un jeu de vitesse de frappe.", + examples: &["+fasttype"], + default_aliases: &["type"], + allow_in_dm: true, + default_permission: 0, + } + } +} diff --git a/src/commands/game/findemoji.rs b/src/commands/game/findemoji.rs new file mode 100644 index 0000000..5e82164 --- /dev/null +++ b/src/commands/game/findemoji.rs @@ -0,0 +1,56 @@ +use rand::seq::SliceRandom; +use serenity::builder::CreateEmbed; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::common::{send_embed, theme_color}; + +pub async fn handle_findemoji(ctx: &Context, msg: &Message, _args: &[&str]) { + let packs = [ + (":cat:", [":cat:", ":dog:", ":fox:", ":bear:"]), + (":star:", [":moon:", ":sunny:", ":star:", ":zap:"]), + (":pizza:", [":hamburger:", ":pizza:", ":fries:", ":hotdog:"]), + ]; + + let (target, options) = { + let mut rng = rand::thread_rng(); + packs + .choose(&mut rng) + .copied() + .unwrap_or((":cat:", [":cat:", ":dog:", ":fox:", ":bear:"])) + }; + + let embed = CreateEmbed::new() + .title("FindEmoji") + .description(format!( + "Trouve l emoji cible: **{}**\nOptions: {}", + target, + options.join(" ") + )) + .field( + "Regle", + "Le premier qui identifie le bon emoji gagne le round.", + false, + ) + .color(theme_color(ctx).await); + + send_embed(ctx, msg, embed).await; +} + +pub struct FindemojiCommand; +pub static COMMAND_DESCRIPTOR: FindemojiCommand = FindemojiCommand; + +impl crate::commands::command_contract::CommandSpec for FindemojiCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + name: "findemoji", + category: "game", + params: "aucun", + description: "Jouer au jeu Trouver l Emoji.", + examples: &["+findemoji"], + default_aliases: &["emojihunt"], + allow_in_dm: true, + default_permission: 0, + } + } +} diff --git a/src/commands/game/flood.rs b/src/commands/game/flood.rs new file mode 100644 index 0000000..de7d6c1 --- /dev/null +++ b/src/commands/game/flood.rs @@ -0,0 +1,53 @@ +use rand::Rng; +use serenity::builder::CreateEmbed; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::common::{send_embed, theme_color}; + +pub async fn handle_flood(ctx: &Context, msg: &Message, _args: &[&str]) { + let palette = ['R', 'V', 'B', 'J', 'M', 'C']; + let mut rows = Vec::new(); + + { + let mut rng = rand::thread_rng(); + for _ in 0..6 { + let mut line = String::new(); + for _ in 0..6 { + let color = palette[rng.gen_range(0..palette.len())]; + line.push(color); + line.push(' '); + } + rows.push(line.trim_end().to_string()); + } + } + + let embed = CreateEmbed::new() + .title("Flood") + .description(format!( + "Objectif: inonder la grille avec une seule couleur en un minimum de coups.\n\n```\n{}\n```", + rows.join("\n") + )) + .field("Couleurs", "R=rouge V=vert B=bleu J=jaune M=magenta C=cyan", false) + .color(theme_color(ctx).await); + + send_embed(ctx, msg, embed).await; +} + +pub struct FloodCommand; +pub static COMMAND_DESCRIPTOR: FloodCommand = FloodCommand; + +impl crate::commands::command_contract::CommandSpec for FloodCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + name: "flood", + category: "game", + params: "aucun", + description: "Jouer au jeu Flood.", + examples: &["+flood"], + default_aliases: &["floodit"], + allow_in_dm: true, + default_permission: 0, + } + } +} diff --git a/src/commands/game/g2048.rs b/src/commands/game/g2048.rs new file mode 100644 index 0000000..5b4e4fd --- /dev/null +++ b/src/commands/game/g2048.rs @@ -0,0 +1,472 @@ +use rand::Rng; +use rand::seq::SliceRandom; +use serde::{Deserialize, Serialize}; +use serenity::all::{ButtonStyle, ComponentInteraction}; +use serenity::builder::{ + CreateActionRow, CreateButton, CreateEmbed, CreateInteractionResponse, + CreateInteractionResponseMessage, CreateMessage, +}; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::common::theme_color; +use crate::db; + +const GAME_KIND: &str = "2048"; +const GAME_PREFIX: &str = "game:2048"; +const SIZE: usize = 4; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct Game2048State { + board: Vec, + owner_id: i64, + score: u32, + over: bool, +} + +async fn pool(ctx: &Context) -> Option { + let data = ctx.data.read().await; + data.get::().cloned() +} + +fn parse_component_id(custom_id: &str) -> Option<(i64, String)> { + let mut parts = custom_id.split(':'); + let scope = parts.next()?; + let game = parts.next()?; + let session_id = parts.next()?.parse::().ok()?; + let action = parts.next()?.to_string(); + + if scope != "game" || game != "2048" || parts.next().is_some() { + return None; + } + + Some((session_id, action)) +} + +fn create_fresh_board() -> Vec { + let mut board = vec![0u16; SIZE * SIZE]; + let _ = spawn_random_tile(&mut board); + let _ = spawn_random_tile(&mut board); + board +} + +fn spawn_random_tile(board: &mut [u16]) -> bool { + let empties = board + .iter() + .enumerate() + .filter_map(|(idx, value)| if *value == 0 { Some(idx) } else { None }) + .collect::>(); + + if empties.is_empty() { + return false; + } + + let picked = { + let mut rng = rand::thread_rng(); + empties.choose(&mut rng).copied() + }; + + let Some(index) = picked else { + return false; + }; + + let value = { + let mut rng = rand::thread_rng(); + if rng.gen_bool(0.9) { 2 } else { 4 } + }; + + board[index] = value; + true +} + +fn slide_merge_line(input: [u16; SIZE]) -> ([u16; SIZE], u32, bool) { + let non_zero = input + .iter() + .copied() + .filter(|value| *value != 0) + .collect::>(); + + let mut merged = Vec::new(); + let mut score_gain = 0u32; + let mut index = 0usize; + + while index < non_zero.len() { + if index + 1 < non_zero.len() && non_zero[index] == non_zero[index + 1] { + let value = non_zero[index] * 2; + merged.push(value); + score_gain = score_gain.saturating_add(value as u32); + index += 2; + } else { + merged.push(non_zero[index]); + index += 1; + } + } + + while merged.len() < SIZE { + merged.push(0); + } + + let output = [merged[0], merged[1], merged[2], merged[3]]; + let changed = output != input; + (output, score_gain, changed) +} + +fn move_left(board: &mut [u16]) -> (bool, u32) { + let mut changed = false; + let mut gain = 0u32; + + for row in 0..SIZE { + let base = row * SIZE; + let line = [ + board[base], + board[base + 1], + board[base + 2], + board[base + 3], + ]; + let (new_line, score, line_changed) = slide_merge_line(line); + board[base] = new_line[0]; + board[base + 1] = new_line[1]; + board[base + 2] = new_line[2]; + board[base + 3] = new_line[3]; + changed |= line_changed; + gain = gain.saturating_add(score); + } + + (changed, gain) +} + +fn move_right(board: &mut [u16]) -> (bool, u32) { + let mut changed = false; + let mut gain = 0u32; + + for row in 0..SIZE { + let base = row * SIZE; + let line = [ + board[base + 3], + board[base + 2], + board[base + 1], + board[base], + ]; + let (new_line, score, line_changed) = slide_merge_line(line); + board[base + 3] = new_line[0]; + board[base + 2] = new_line[1]; + board[base + 1] = new_line[2]; + board[base] = new_line[3]; + changed |= line_changed; + gain = gain.saturating_add(score); + } + + (changed, gain) +} + +fn move_up(board: &mut [u16]) -> (bool, u32) { + let mut changed = false; + let mut gain = 0u32; + + for col in 0..SIZE { + let line = [ + board[col], + board[SIZE + col], + board[(2 * SIZE) + col], + board[(3 * SIZE) + col], + ]; + let (new_line, score, line_changed) = slide_merge_line(line); + board[col] = new_line[0]; + board[SIZE + col] = new_line[1]; + board[(2 * SIZE) + col] = new_line[2]; + board[(3 * SIZE) + col] = new_line[3]; + changed |= line_changed; + gain = gain.saturating_add(score); + } + + (changed, gain) +} + +fn move_down(board: &mut [u16]) -> (bool, u32) { + let mut changed = false; + let mut gain = 0u32; + + for col in 0..SIZE { + let line = [ + board[(3 * SIZE) + col], + board[(2 * SIZE) + col], + board[SIZE + col], + board[col], + ]; + let (new_line, score, line_changed) = slide_merge_line(line); + board[(3 * SIZE) + col] = new_line[0]; + board[(2 * SIZE) + col] = new_line[1]; + board[SIZE + col] = new_line[2]; + board[col] = new_line[3]; + changed |= line_changed; + gain = gain.saturating_add(score); + } + + (changed, gain) +} + +fn has_possible_moves(board: &[u16]) -> bool { + if board.iter().any(|value| *value == 0) { + return true; + } + + for row in 0..SIZE { + for col in 0..SIZE { + let current = board[(row * SIZE) + col]; + + if col + 1 < SIZE && board[(row * SIZE) + (col + 1)] == current { + return true; + } + + if row + 1 < SIZE && board[((row + 1) * SIZE) + col] == current { + return true; + } + } + } + + false +} + +fn apply_action(state: &mut Game2048State, action: &str) -> Result<(), &'static str> { + if action == "reset" { + state.board = create_fresh_board(); + state.score = 0; + state.over = false; + return Ok(()); + } + + if state.over { + return Err("La partie est terminee. Utilise le bouton Reset."); + } + + let (changed, gain) = match action { + "left" => move_left(&mut state.board), + "right" => move_right(&mut state.board), + "up" => move_up(&mut state.board), + "down" => move_down(&mut state.board), + _ => return Err("Action inconnue."), + }; + + if !changed { + return Err("Coup impossible dans cette direction."); + } + + state.score = state.score.saturating_add(gain); + let _ = spawn_random_tile(&mut state.board); + state.over = !has_possible_moves(&state.board); + Ok(()) +} + +fn board_text(board: &[u16]) -> String { + let mut lines = Vec::new(); + + for row in 0..SIZE { + let mut cells = Vec::new(); + for col in 0..SIZE { + let value = board[(row * SIZE) + col]; + if value == 0 { + cells.push(format!("{:>5}", ".")); + } else { + cells.push(format!("{:>5}", value)); + } + } + lines.push(cells.join(" ")); + } + + lines.join("\n") +} + +fn game_components(session_id: i64, over: bool) -> Vec { + vec![ + CreateActionRow::Buttons(vec![ + CreateButton::new(format!("{}:{}:{}", GAME_PREFIX, session_id, "up")) + .label("Haut") + .style(ButtonStyle::Primary) + .disabled(over), + CreateButton::new(format!("{}:{}:{}", GAME_PREFIX, session_id, "reset")) + .label("Reset") + .style(ButtonStyle::Danger), + ]), + CreateActionRow::Buttons(vec![ + CreateButton::new(format!("{}:{}:{}", GAME_PREFIX, session_id, "left")) + .label("Gauche") + .style(ButtonStyle::Primary) + .disabled(over), + CreateButton::new(format!("{}:{}:{}", GAME_PREFIX, session_id, "down")) + .label("Bas") + .style(ButtonStyle::Primary) + .disabled(over), + CreateButton::new(format!("{}:{}:{}", GAME_PREFIX, session_id, "right")) + .label("Droite") + .style(ButtonStyle::Primary) + .disabled(over), + ]), + ] +} + +fn game_embed(session_id: i64, state: &Game2048State, color: u32) -> CreateEmbed { + CreateEmbed::new() + .title("2048 interactif") + .description(format!( + "Session `#{}`\n\n```\n{}\n```", + session_id, + board_text(&state.board) + )) + .field("Score", state.score.to_string(), true) + .field( + "Etat", + if state.over { "Terminee" } else { "En cours" }, + true, + ) + .color(color) +} + +async fn send_ephemeral(ctx: &Context, component: &ComponentInteraction, content: &str) { + let _ = component + .create_response( + &ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content(content) + .ephemeral(true), + ), + ) + .await; +} + +pub async fn handle_2048(ctx: &Context, msg: &Message, _args: &[&str]) { + let Some(pool) = pool(ctx).await else { + let _ = msg + .channel_id + .send_message( + &ctx.http, + CreateMessage::new().embed( + CreateEmbed::new() + .title("2048") + .description("Base de donnees indisponible, impossible de demarrer une session interactive.") + .color(0xED4245), + ), + ) + .await; + return; + }; + + let owner_id = msg.author.id.get() as i64; + let state = Game2048State { + board: create_fresh_board(), + owner_id, + score: 0, + over: false, + }; + + let participants_json = + serde_json::to_string(&vec![owner_id]).unwrap_or_else(|_| "[]".to_string()); + let state_json = serde_json::to_string(&state).unwrap_or_else(|_| "{}".to_string()); + let bot_id = ctx.cache.current_user().id.get() as i64; + + let Ok(session) = db::create_game_session( + &pool, + bot_id, + msg.guild_id.map(|id| id.get() as i64), + msg.channel_id.get() as i64, + owner_id, + GAME_KIND, + &participants_json, + &state_json, + ) + .await + else { + return; + }; + + let color = theme_color(ctx).await; + let sent = msg + .channel_id + .send_message( + &ctx.http, + CreateMessage::new() + .embed(game_embed(session.id, &state, color)) + .components(game_components(session.id, state.over)), + ) + .await; + + if let Ok(message) = sent { + let _ = db::set_game_session_message(&pool, session.id, message.id.get() as i64).await; + } +} + +pub async fn handle_component_interaction(ctx: &Context, component: &ComponentInteraction) -> bool { + if !component.data.custom_id.starts_with(GAME_PREFIX) { + return false; + } + + let Some((session_id, action)) = parse_component_id(&component.data.custom_id) else { + return false; + }; + + let Some(pool) = pool(ctx).await else { + send_ephemeral(ctx, component, "Base de donnees indisponible.").await; + return true; + }; + + let Ok(Some(session)) = db::get_game_session(&pool, session_id).await else { + send_ephemeral(ctx, component, "Session introuvable.").await; + return true; + }; + + if session.game_type != GAME_KIND { + return false; + } + + let Ok(mut state) = serde_json::from_str::(&session.state_json) else { + send_ephemeral(ctx, component, "Etat de session invalide.").await; + return true; + }; + + let actor_id = component.user.id.get() as i64; + if actor_id != state.owner_id { + send_ephemeral(ctx, component, "Seul le createur de la partie peut jouer.").await; + return true; + } + + if let Err(message) = apply_action(&mut state, &action) { + send_ephemeral(ctx, component, message).await; + return true; + } + + let status = if state.over { "finished" } else { "active" }; + let state_json = serde_json::to_string(&state).unwrap_or_else(|_| session.state_json.clone()); + let _ = db::update_game_session_state(&pool, session.id, &state_json, status).await; + + let color = theme_color(ctx).await; + let _ = component + .create_response( + &ctx.http, + CreateInteractionResponse::UpdateMessage( + CreateInteractionResponseMessage::new() + .embed(game_embed(session.id, &state, color)) + .components(game_components(session.id, state.over)), + ), + ) + .await; + + true +} + +pub struct Game2048Command; +pub static COMMAND_DESCRIPTOR: Game2048Command = Game2048Command; + +impl crate::commands::command_contract::CommandSpec for Game2048Command { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + name: "2048", + category: "game", + params: "aucun", + description: "Jouer au jeu 2048.", + examples: &["+2048"], + default_aliases: &["g2048"], + allow_in_dm: true, + default_permission: 0, + } + } +} diff --git a/src/commands/game/guesspokemon.rs b/src/commands/game/guesspokemon.rs new file mode 100644 index 0000000..d4c2bb0 --- /dev/null +++ b/src/commands/game/guesspokemon.rs @@ -0,0 +1,57 @@ +use rand::seq::SliceRandom; +use serenity::builder::CreateEmbed; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::common::{send_embed, theme_color}; + +pub async fn handle_guesspokemon(ctx: &Context, msg: &Message, _args: &[&str]) { + let pokemons = [ + "pikachu", + "bulbizarre", + "salameche", + "carapuce", + "evoli", + "dracaufeu", + ]; + + let name = { + let mut rng = rand::thread_rng(); + pokemons.choose(&mut rng).copied().unwrap_or("pikachu") + }; + let hint = format!( + "{}{}", + &name[0..1], + "_".repeat(name.chars().count().saturating_sub(1)) + ); + + let embed = CreateEmbed::new() + .title("GuessPokemon") + .description(format!( + "Qui est ce pokemon ?\nIndice: **{}** ({} lettres)", + hint, + name.chars().count() + )) + .field("Reponse", "Le premier a donner le bon nom gagne.", false) + .color(theme_color(ctx).await); + + send_embed(ctx, msg, embed).await; +} + +pub struct GuesspokemonCommand; +pub static COMMAND_DESCRIPTOR: GuesspokemonCommand = GuesspokemonCommand; + +impl crate::commands::command_contract::CommandSpec for GuesspokemonCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + name: "guesspokemon", + category: "game", + params: "aucun", + description: "Jouer au jeu trouver le pokemon.", + examples: &["+guesspokemon"], + default_aliases: &["pokemon"], + allow_in_dm: true, + default_permission: 0, + } + } +} diff --git a/src/commands/game/halloween.rs b/src/commands/game/halloween.rs new file mode 100644 index 0000000..5304fea --- /dev/null +++ b/src/commands/game/halloween.rs @@ -0,0 +1,44 @@ +use chrono::{Datelike, NaiveDate, Utc}; +use serenity::builder::CreateEmbed; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::common::{send_embed, theme_color}; + +pub async fn handle_halloween(ctx: &Context, msg: &Message, _args: &[&str]) { + let today = Utc::now().date_naive(); + let mut year = today.year(); + let mut target = NaiveDate::from_ymd_opt(year, 10, 31).unwrap_or(today); + + if today > target { + year += 1; + target = NaiveDate::from_ymd_opt(year, 10, 31).unwrap_or(today); + } + + let days = (target - today).num_days(); + + let embed = CreateEmbed::new() + .title("Halloween") + .description(format!("Il reste **{}** jour(s) avant Halloween.", days)) + .color(theme_color(ctx).await); + + send_embed(ctx, msg, embed).await; +} + +pub struct HalloweenCommand; +pub static COMMAND_DESCRIPTOR: HalloweenCommand = HalloweenCommand; + +impl crate::commands::command_contract::CommandSpec for HalloweenCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + name: "halloween", + category: "game", + params: "aucun", + description: "Calcule le nombre de jours jusqu'a Halloween.", + examples: &["+halloween"], + default_aliases: &["spooky"], + allow_in_dm: true, + default_permission: 0, + } + } +} diff --git a/src/commands/game/kiss.rs b/src/commands/game/kiss.rs new file mode 100644 index 0000000..40fcfcd --- /dev/null +++ b/src/commands/game/kiss.rs @@ -0,0 +1,51 @@ +use serenity::builder::CreateEmbed; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::common::{mention_user, send_embed, theme_color}; + +pub async fn handle_kiss(ctx: &Context, msg: &Message, args: &[&str]) { + let target = msg + .mentions + .first() + .map(|user| mention_user(user.id)) + .or_else(|| { + args.first().map(|raw| { + if raw.starts_with("<@") { + raw.to_string() + } else { + format!("**{}**", raw) + } + }) + }) + .unwrap_or_else(|| mention_user(msg.author.id)); + + let embed = CreateEmbed::new() + .title("Kiss") + .description(format!( + "{} fait un bisou a {}.", + mention_user(msg.author.id), + target + )) + .color(theme_color(ctx).await); + + send_embed(ctx, msg, embed).await; +} + +pub struct KissCommand; +pub static COMMAND_DESCRIPTOR: KissCommand = KissCommand; + +impl crate::commands::command_contract::CommandSpec for KissCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + name: "kiss", + category: "game", + params: "[@user]", + description: "Fait un bisou a un utilisateur mentionne ou a un utilisateur.", + examples: &["+kiss", "+kiss @Pseudo"], + default_aliases: &["bisou"], + allow_in_dm: true, + default_permission: 0, + } + } +} diff --git a/src/commands/game/marry.rs b/src/commands/game/marry.rs new file mode 100644 index 0000000..66d1ca8 --- /dev/null +++ b/src/commands/game/marry.rs @@ -0,0 +1,59 @@ +use serenity::builder::CreateEmbed; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::common::{mention_user, send_embed, theme_color}; + +pub async fn handle_marry(ctx: &Context, msg: &Message, args: &[&str]) { + let target = msg + .mentions + .first() + .map(|user| mention_user(user.id)) + .or_else(|| { + args.first().map(|raw| { + if raw.starts_with("<@") { + raw.to_string() + } else { + format!("**{}**", raw) + } + }) + }); + + let Some(target) = target else { + let embed = CreateEmbed::new() + .title("Marry") + .description("Utilise `+marry <@user>` pour faire une demande.") + .color(0xED4245); + send_embed(ctx, msg, embed).await; + return; + }; + + let embed = CreateEmbed::new() + .title("Marry") + .description(format!( + "{} propose en mariage a {}. Reponse attendue dans le chat.", + mention_user(msg.author.id), + target + )) + .color(theme_color(ctx).await); + + send_embed(ctx, msg, embed).await; +} + +pub struct MarryCommand; +pub static COMMAND_DESCRIPTOR: MarryCommand = MarryCommand; + +impl crate::commands::command_contract::CommandSpec for MarryCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + name: "marry", + category: "game", + params: "<@user>", + description: "Proposez en mariage a un utilisateur.", + examples: &["+marry @Pseudo"], + default_aliases: &["proposal"], + allow_in_dm: true, + default_permission: 0, + } + } +} diff --git a/src/commands/game/morpion.rs b/src/commands/game/morpion.rs new file mode 100644 index 0000000..03ff233 --- /dev/null +++ b/src/commands/game/morpion.rs @@ -0,0 +1,448 @@ +use rand::seq::SliceRandom; +use serde::{Deserialize, Serialize}; +use serenity::all::{ButtonStyle, ComponentInteraction}; +use serenity::builder::{ + CreateActionRow, CreateButton, CreateEmbed, CreateInteractionResponse, + CreateInteractionResponseMessage, CreateMessage, +}; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::common::theme_color; +use crate::db; + +const GAME_KIND: &str = "morpion"; +const GAME_PREFIX: &str = "game:morpion"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct MorpionState { + board: Vec, + player_x: i64, + player_o: i64, + current_turn: i64, + winner: i64, + moves: u8, + vs_bot: bool, +} + +async fn pool(ctx: &Context) -> Option { + let data = ctx.data.read().await; + data.get::().cloned() +} + +fn parse_component_id(custom_id: &str) -> Option<(i64, usize)> { + let mut parts = custom_id.split(':'); + let scope = parts.next()?; + let game = parts.next()?; + let session_id = parts.next()?.parse::().ok()?; + let cell = parts.next()?.parse::().ok()?; + + if scope != "game" || game != "morpion" || cell >= 9 || parts.next().is_some() { + return None; + } + + Some((session_id, cell)) +} + +fn player_name(id: i64) -> String { + if id == 0 { + "Bot".to_string() + } else { + format!("<@{}>", id) + } +} + +fn render_board(state: &MorpionState) -> String { + let mut rows = Vec::new(); + + for row in 0..3 { + let mut values = Vec::new(); + for col in 0..3 { + let index = row * 3 + col; + let value = state.board[index]; + let label = match value { + 1 => "X".to_string(), + 2 => "O".to_string(), + _ => (index + 1).to_string(), + }; + values.push(label); + } + rows.push(values.join(" | ")); + } + + rows.join("\n---------\n") +} + +fn has_winner(board: &[u8], mark: u8) -> bool { + const PATTERNS: [[usize; 3]; 8] = [ + [0, 1, 2], + [3, 4, 5], + [6, 7, 8], + [0, 3, 6], + [1, 4, 7], + [2, 5, 8], + [0, 4, 8], + [2, 4, 6], + ]; + + PATTERNS + .iter() + .any(|pattern| pattern.iter().all(|index| board[*index] == mark)) +} + +fn available_cells(board: &[u8]) -> Vec { + board + .iter() + .enumerate() + .filter_map(|(index, value)| if *value == 0 { Some(index) } else { None }) + .collect() +} + +fn apply_player_move( + state: &mut MorpionState, + cell: usize, + actor_id: i64, +) -> Result<(), &'static str> { + if state.winner != 0 { + return Err("La partie est deja terminee."); + } + + if state.current_turn != actor_id { + return Err("Ce n'est pas ton tour."); + } + + if state.board.get(cell).copied().unwrap_or(9) != 0 { + return Err("Cette case est deja occupee."); + } + + let mark = if actor_id == state.player_x { + 1 + } else if actor_id == state.player_o { + 2 + } else { + return Err("Tu n'es pas un joueur de cette partie."); + }; + + state.board[cell] = mark; + state.moves = state.moves.saturating_add(1); + + if has_winner(&state.board, mark) { + state.winner = if state.vs_bot && mark == 2 { + -2 + } else { + actor_id + }; + state.current_turn = 0; + return Ok(()); + } + + if state.moves >= 9 { + state.winner = -1; + state.current_turn = 0; + return Ok(()); + } + + state.current_turn = if actor_id == state.player_x { + state.player_o + } else { + state.player_x + }; + + Ok(()) +} + +fn apply_bot_move(state: &mut MorpionState) { + if !state.vs_bot || state.winner != 0 || state.current_turn != 0 { + return; + } + + let empties = available_cells(&state.board); + let bot_cell = { + let mut rng = rand::thread_rng(); + empties.choose(&mut rng).copied() + }; + + let Some(cell) = bot_cell else { + state.winner = -1; + return; + }; + + state.board[cell] = 2; + state.moves = state.moves.saturating_add(1); + + if has_winner(&state.board, 2) { + state.winner = -2; + state.current_turn = 0; + return; + } + + if state.moves >= 9 { + state.winner = -1; + state.current_turn = 0; + return; + } + + state.current_turn = state.player_x; +} + +fn game_components(session_id: i64, state: &MorpionState) -> Vec { + let mut rows = Vec::new(); + + for row in 0..3 { + let mut buttons = Vec::new(); + + for col in 0..3 { + let index = row * 3 + col; + let value = state.board[index]; + let label = match value { + 1 => "X".to_string(), + 2 => "O".to_string(), + _ => (index + 1).to_string(), + }; + + let style = match value { + 1 => ButtonStyle::Danger, + 2 => ButtonStyle::Success, + _ => ButtonStyle::Secondary, + }; + + let disabled = state.winner != 0 || value != 0; + buttons.push( + CreateButton::new(format!("{}:{}:{}", GAME_PREFIX, session_id, index)) + .label(label) + .style(style) + .disabled(disabled), + ); + } + + rows.push(CreateActionRow::Buttons(buttons)); + } + + rows +} + +fn game_embed(session_id: i64, state: &MorpionState, color: u32) -> CreateEmbed { + let status = if state.winner == 0 { + format!("Tour de {}.", player_name(state.current_turn)) + } else if state.winner == -1 { + "Match nul.".to_string() + } else if state.winner == -2 { + "Le bot gagne.".to_string() + } else { + format!("Victoire de {}.", player_name(state.winner)) + }; + + CreateEmbed::new() + .title("Morpion interactif") + .description(format!( + "Session `#{}`\n\n```\n{}\n```", + session_id, + render_board(state) + )) + .field("Joueur X", player_name(state.player_x), true) + .field("Joueur O", player_name(state.player_o), true) + .field("Etat", status, false) + .color(color) +} + +async fn send_ephemeral(ctx: &Context, component: &ComponentInteraction, content: &str) { + let _ = component + .create_response( + &ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content(content) + .ephemeral(true), + ), + ) + .await; +} + +pub async fn handle_morpion(ctx: &Context, msg: &Message, _args: &[&str]) { + let Some(pool) = pool(ctx).await else { + let _ = msg + .channel_id + .send_message( + &ctx.http, + CreateMessage::new().embed( + CreateEmbed::new() + .title("Morpion") + .description("Base de donnees indisponible, impossible de demarrer une session interactive.") + .color(0xED4245), + ), + ) + .await; + return; + }; + + let player_x = msg.author.id.get() as i64; + let player_o = msg + .mentions + .first() + .filter(|user| user.id != msg.author.id) + .map(|user| user.id.get() as i64) + .unwrap_or(0); + let vs_bot = player_o == 0; + + let state = MorpionState { + board: vec![0; 9], + player_x, + player_o, + current_turn: player_x, + winner: 0, + moves: 0, + vs_bot, + }; + + let participants = if vs_bot { + vec![player_x] + } else { + vec![player_x, player_o] + }; + + let participants_json = + serde_json::to_string(&participants).unwrap_or_else(|_| "[]".to_string()); + let state_json = serde_json::to_string(&state).unwrap_or_else(|_| "{}".to_string()); + let bot_id = ctx.cache.current_user().id.get() as i64; + + let Ok(session) = db::create_game_session( + &pool, + bot_id, + msg.guild_id.map(|id| id.get() as i64), + msg.channel_id.get() as i64, + player_x, + GAME_KIND, + &participants_json, + &state_json, + ) + .await + else { + return; + }; + + let color = theme_color(ctx).await; + let sent = msg + .channel_id + .send_message( + &ctx.http, + CreateMessage::new() + .embed(game_embed(session.id, &state, color)) + .components(game_components(session.id, &state)), + ) + .await; + + if let Ok(message) = sent { + let _ = db::set_game_session_message(&pool, session.id, message.id.get() as i64).await; + } +} + +pub async fn handle_component_interaction(ctx: &Context, component: &ComponentInteraction) -> bool { + if !component.data.custom_id.starts_with(GAME_PREFIX) { + return false; + } + + let Some((session_id, cell)) = parse_component_id(&component.data.custom_id) else { + return false; + }; + + let Some(pool) = pool(ctx).await else { + send_ephemeral(ctx, component, "Base de donnees indisponible.").await; + return true; + }; + + let Ok(Some(session)) = db::get_game_session(&pool, session_id).await else { + send_ephemeral(ctx, component, "Session introuvable.").await; + return true; + }; + + if session.game_type != GAME_KIND { + return false; + } + + let Ok(mut state) = serde_json::from_str::(&session.state_json) else { + send_ephemeral(ctx, component, "Etat de session invalide.").await; + return true; + }; + + if session.status != "active" || state.winner != 0 { + let color = theme_color(ctx).await; + let _ = component + .create_response( + &ctx.http, + CreateInteractionResponse::UpdateMessage( + CreateInteractionResponseMessage::new() + .embed(game_embed(session.id, &state, color)) + .components(game_components(session.id, &state)), + ), + ) + .await; + return true; + } + + let actor_id = component.user.id.get() as i64; + + if state.vs_bot { + if actor_id != state.player_x { + send_ephemeral( + ctx, + component, + "Seul le createur de la partie peut jouer contre le bot.", + ) + .await; + return true; + } + } else if actor_id != state.current_turn { + send_ephemeral(ctx, component, "Ce n'est pas ton tour.").await; + return true; + } + + if let Err(error) = apply_player_move(&mut state, cell, actor_id) { + send_ephemeral(ctx, component, error).await; + return true; + } + + if state.vs_bot && state.winner == 0 { + state.current_turn = 0; + apply_bot_move(&mut state); + } + + let status = if state.winner == 0 { + "active" + } else { + "finished" + }; + let state_json = serde_json::to_string(&state).unwrap_or_else(|_| session.state_json.clone()); + let _ = db::update_game_session_state(&pool, session.id, &state_json, status).await; + + let color = theme_color(ctx).await; + let _ = component + .create_response( + &ctx.http, + CreateInteractionResponse::UpdateMessage( + CreateInteractionResponseMessage::new() + .embed(game_embed(session.id, &state, color)) + .components(game_components(session.id, &state)), + ), + ) + .await; + + true +} + +pub struct MorpionCommand; +pub static COMMAND_DESCRIPTOR: MorpionCommand = MorpionCommand; + +impl crate::commands::command_contract::CommandSpec for MorpionCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + name: "morpion", + category: "game", + params: "aucun", + description: "Jouer a morpion.", + examples: &["+morpion"], + default_aliases: &["tic", "tactoe"], + allow_in_dm: true, + default_permission: 0, + } + } +} diff --git a/src/commands/game/pendu.rs b/src/commands/game/pendu.rs new file mode 100644 index 0000000..b62d887 --- /dev/null +++ b/src/commands/game/pendu.rs @@ -0,0 +1,69 @@ +use rand::seq::SliceRandom; +use serenity::builder::CreateEmbed; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::common::{send_embed, theme_color}; + +pub async fn handle_pendu(ctx: &Context, msg: &Message, _args: &[&str]) { + let words = [ + "discord", + "shadowbot", + "rust", + "moderation", + "serenity", + "serveur", + ]; + + let word = { + let mut rng = rand::thread_rng(); + words.choose(&mut rng).copied().unwrap_or("rust") + }; + let mut chars = word.chars().collect::>(); + + if chars.len() > 2 { + for index in 1..chars.len() - 1 { + chars[index] = '_'; + } + } + + let masked = chars + .iter() + .map(|c| c.to_string()) + .collect::>() + .join(" "); + + let embed = CreateEmbed::new() + .title("Pendu") + .description(format!( + "Mot mystere: **{}**\nIndice: {} lettres.", + masked, + word.chars().count() + )) + .field( + "Astuce", + "Tu peux jouer en discutant les propositions dans le salon.", + false, + ) + .color(theme_color(ctx).await); + + send_embed(ctx, msg, embed).await; +} + +pub struct PenduCommand; +pub static COMMAND_DESCRIPTOR: PenduCommand = PenduCommand; + +impl crate::commands::command_contract::CommandSpec for PenduCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + name: "pendu", + category: "game", + params: "aucun", + description: "Jouer au jeu du pendu.", + examples: &["+pendu"], + default_aliases: &["hangman"], + allow_in_dm: true, + default_permission: 0, + } + } +} diff --git a/src/commands/game/pfc.rs b/src/commands/game/pfc.rs new file mode 100644 index 0000000..6536dc2 --- /dev/null +++ b/src/commands/game/pfc.rs @@ -0,0 +1,73 @@ +use rand::seq::SliceRandom; +use serenity::builder::CreateEmbed; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::common::{send_embed, theme_color}; + +pub async fn handle_pfc(ctx: &Context, msg: &Message, args: &[&str]) { + let choices = ["pierre", "papier", "ciseaux"]; + + if args.is_empty() { + let embed = CreateEmbed::new() + .title("Pierre Papier Ciseaux") + .description("Utilise `+pfc ` pour jouer.") + .color(theme_color(ctx).await); + send_embed(ctx, msg, embed).await; + return; + } + + let player = args[0].to_lowercase(); + if !choices.iter().any(|choice| player == *choice) { + let embed = CreateEmbed::new() + .title("PFC") + .description("Choix invalide. Valeurs attendues: pierre, papier ou ciseaux.") + .color(0xED4245); + send_embed(ctx, msg, embed).await; + return; + } + + let bot = { + let mut rng = rand::thread_rng(); + choices.choose(&mut rng).copied().unwrap_or("pierre") + }; + + let result = if player == bot { + "Egalite." + } else if (player == "pierre" && bot == "ciseaux") + || (player == "papier" && bot == "pierre") + || (player == "ciseaux" && bot == "papier") + { + "Tu gagnes." + } else { + "Tu perds." + }; + + let embed = CreateEmbed::new() + .title("PFC") + .description(format!( + "Ton choix: **{}**\nChoix du bot: **{}**\n\n{}", + player, bot, result + )) + .color(theme_color(ctx).await); + + send_embed(ctx, msg, embed).await; +} + +pub struct PfcCommand; +pub static COMMAND_DESCRIPTOR: PfcCommand = PfcCommand; + +impl crate::commands::command_contract::CommandSpec for PfcCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + name: "pfc", + category: "game", + params: "", + description: "Jouer a pierre-papier-ciseaux.", + examples: &["+pfc pierre"], + default_aliases: &["rps"], + allow_in_dm: true, + default_permission: 0, + } + } +} diff --git a/src/commands/game/puissance4.rs b/src/commands/game/puissance4.rs new file mode 100644 index 0000000..0da19ce --- /dev/null +++ b/src/commands/game/puissance4.rs @@ -0,0 +1,468 @@ +use rand::seq::SliceRandom; +use serde::{Deserialize, Serialize}; +use serenity::all::{ButtonStyle, ComponentInteraction}; +use serenity::builder::{ + CreateActionRow, CreateButton, CreateEmbed, CreateInteractionResponse, + CreateInteractionResponseMessage, CreateMessage, +}; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::common::theme_color; +use crate::db; + +const GAME_KIND: &str = "puissance4"; +const GAME_PREFIX: &str = "game:p4"; +const WIDTH: usize = 7; +const HEIGHT: usize = 6; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct Puissance4State { + board: Vec, + player_red: i64, + player_yellow: i64, + current_turn: i64, + winner: i64, + moves: u8, + vs_bot: bool, +} + +async fn pool(ctx: &Context) -> Option { + let data = ctx.data.read().await; + data.get::().cloned() +} + +fn index(row: usize, col: usize) -> usize { + row * WIDTH + col +} + +fn parse_component_id(custom_id: &str) -> Option<(i64, usize)> { + let mut parts = custom_id.split(':'); + let scope = parts.next()?; + let game = parts.next()?; + let session_id = parts.next()?.parse::().ok()?; + let col = parts.next()?.parse::().ok()?; + + if scope != "game" || game != "p4" || col >= WIDTH || parts.next().is_some() { + return None; + } + + Some((session_id, col)) +} + +fn player_name(id: i64) -> String { + if id == 0 { + "Bot".to_string() + } else { + format!("<@{}>", id) + } +} + +fn board_text(state: &Puissance4State) -> String { + let mut lines = Vec::new(); + + for row in 0..HEIGHT { + let mut values = Vec::new(); + for col in 0..WIDTH { + let value = state.board[index(row, col)]; + let cell = match value { + 1 => "R", + 2 => "Y", + _ => ".", + }; + values.push(cell.to_string()); + } + lines.push(format!("| {} |", values.join(" "))); + } + + lines.push(" 1 2 3 4 5 6 7".to_string()); + lines.join("\n") +} + +fn drop_piece(board: &mut [u8], col: usize, mark: u8) -> Option { + for row in (0..HEIGHT).rev() { + let idx = index(row, col); + if board[idx] == 0 { + board[idx] = mark; + return Some(row); + } + } + + None +} + +fn check_winner_at(board: &[u8], row: usize, col: usize, mark: u8) -> bool { + let directions = [(1isize, 0isize), (0, 1), (1, 1), (1, -1)]; + + for (dr, dc) in directions { + let mut count = 1; + + for sign in [-1isize, 1isize] { + let mut r = row as isize; + let mut c = col as isize; + + loop { + r += dr * sign; + c += dc * sign; + + if r < 0 || r >= HEIGHT as isize || c < 0 || c >= WIDTH as isize { + break; + } + + if board[index(r as usize, c as usize)] != mark { + break; + } + + count += 1; + } + } + + if count >= 4 { + return true; + } + } + + false +} + +fn valid_columns(board: &[u8]) -> Vec { + (0..WIDTH) + .filter(|col| board[index(0, *col)] == 0) + .collect::>() +} + +fn apply_turn(state: &mut Puissance4State, col: usize, actor_id: i64) -> Result<(), &'static str> { + if state.winner != 0 { + return Err("La partie est deja terminee."); + } + + if state.current_turn != actor_id { + return Err("Ce n'est pas ton tour."); + } + + let mark = if actor_id == state.player_red { + 1 + } else if actor_id == state.player_yellow { + 2 + } else { + return Err("Tu n'es pas un joueur de cette partie."); + }; + + let Some(row) = drop_piece(&mut state.board, col, mark) else { + return Err("Cette colonne est pleine."); + }; + + state.moves = state.moves.saturating_add(1); + + if check_winner_at(&state.board, row, col, mark) { + state.winner = if state.vs_bot && mark == 2 { + -2 + } else { + actor_id + }; + state.current_turn = 0; + return Ok(()); + } + + if state.moves as usize >= WIDTH * HEIGHT { + state.winner = -1; + state.current_turn = 0; + return Ok(()); + } + + state.current_turn = if actor_id == state.player_red { + state.player_yellow + } else { + state.player_red + }; + + Ok(()) +} + +fn apply_bot_turn(state: &mut Puissance4State) { + if !state.vs_bot || state.winner != 0 || state.current_turn != 0 { + return; + } + + let valid = valid_columns(&state.board); + let col = { + let mut rng = rand::thread_rng(); + valid.choose(&mut rng).copied() + }; + + let Some(col) = col else { + state.winner = -1; + return; + }; + + let Some(row) = drop_piece(&mut state.board, col, 2) else { + state.winner = -1; + return; + }; + + state.moves = state.moves.saturating_add(1); + + if check_winner_at(&state.board, row, col, 2) { + state.winner = -2; + state.current_turn = 0; + return; + } + + if state.moves as usize >= WIDTH * HEIGHT { + state.winner = -1; + state.current_turn = 0; + return; + } + + state.current_turn = state.player_red; +} + +fn game_components(session_id: i64, state: &Puissance4State) -> Vec { + let valid = valid_columns(&state.board); + let mut first_row = Vec::new(); + let mut second_row = Vec::new(); + + for col in 0..WIDTH { + let button = CreateButton::new(format!("{}:{}:{}", GAME_PREFIX, session_id, col)) + .label((col + 1).to_string()) + .style(ButtonStyle::Primary) + .disabled(state.winner != 0 || !valid.contains(&col)); + + if col < 4 { + first_row.push(button); + } else { + second_row.push(button); + } + } + + vec![ + CreateActionRow::Buttons(first_row), + CreateActionRow::Buttons(second_row), + ] +} + +fn game_embed(session_id: i64, state: &Puissance4State, color: u32) -> CreateEmbed { + let status = if state.winner == 0 { + format!("Tour de {}.", player_name(state.current_turn)) + } else if state.winner == -1 { + "Match nul.".to_string() + } else if state.winner == -2 { + "Le bot gagne.".to_string() + } else { + format!("Victoire de {}.", player_name(state.winner)) + }; + + CreateEmbed::new() + .title("Puissance4 interactif") + .description(format!( + "Session `#{}`\n\n```\n{}\n```", + session_id, + board_text(state) + )) + .field("Rouge", player_name(state.player_red), true) + .field("Jaune", player_name(state.player_yellow), true) + .field("Etat", status, false) + .color(color) +} + +async fn send_ephemeral(ctx: &Context, component: &ComponentInteraction, content: &str) { + let _ = component + .create_response( + &ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content(content) + .ephemeral(true), + ), + ) + .await; +} + +pub async fn handle_puissance4(ctx: &Context, msg: &Message, _args: &[&str]) { + let Some(pool) = pool(ctx).await else { + let _ = msg + .channel_id + .send_message( + &ctx.http, + CreateMessage::new().embed( + CreateEmbed::new() + .title("Puissance4") + .description("Base de donnees indisponible, impossible de demarrer une session interactive.") + .color(0xED4245), + ), + ) + .await; + return; + }; + + let player_red = msg.author.id.get() as i64; + let player_yellow = msg + .mentions + .first() + .filter(|user| user.id != msg.author.id) + .map(|user| user.id.get() as i64) + .unwrap_or(0); + let vs_bot = player_yellow == 0; + + let state = Puissance4State { + board: vec![0; WIDTH * HEIGHT], + player_red, + player_yellow, + current_turn: player_red, + winner: 0, + moves: 0, + vs_bot, + }; + + let participants = if vs_bot { + vec![player_red] + } else { + vec![player_red, player_yellow] + }; + + let participants_json = + serde_json::to_string(&participants).unwrap_or_else(|_| "[]".to_string()); + let state_json = serde_json::to_string(&state).unwrap_or_else(|_| "{}".to_string()); + let bot_id = ctx.cache.current_user().id.get() as i64; + + let Ok(session) = db::create_game_session( + &pool, + bot_id, + msg.guild_id.map(|id| id.get() as i64), + msg.channel_id.get() as i64, + player_red, + GAME_KIND, + &participants_json, + &state_json, + ) + .await + else { + return; + }; + + let color = theme_color(ctx).await; + let sent = msg + .channel_id + .send_message( + &ctx.http, + CreateMessage::new() + .embed(game_embed(session.id, &state, color)) + .components(game_components(session.id, &state)), + ) + .await; + + if let Ok(message) = sent { + let _ = db::set_game_session_message(&pool, session.id, message.id.get() as i64).await; + } +} + +pub async fn handle_component_interaction(ctx: &Context, component: &ComponentInteraction) -> bool { + if !component.data.custom_id.starts_with(GAME_PREFIX) { + return false; + } + + let Some((session_id, col)) = parse_component_id(&component.data.custom_id) else { + return false; + }; + + let Some(pool) = pool(ctx).await else { + send_ephemeral(ctx, component, "Base de donnees indisponible.").await; + return true; + }; + + let Ok(Some(session)) = db::get_game_session(&pool, session_id).await else { + send_ephemeral(ctx, component, "Session introuvable.").await; + return true; + }; + + if session.game_type != GAME_KIND { + return false; + } + + let Ok(mut state) = serde_json::from_str::(&session.state_json) else { + send_ephemeral(ctx, component, "Etat de session invalide.").await; + return true; + }; + + if session.status != "active" || state.winner != 0 { + let color = theme_color(ctx).await; + let _ = component + .create_response( + &ctx.http, + CreateInteractionResponse::UpdateMessage( + CreateInteractionResponseMessage::new() + .embed(game_embed(session.id, &state, color)) + .components(game_components(session.id, &state)), + ), + ) + .await; + return true; + } + + let actor_id = component.user.id.get() as i64; + + if state.vs_bot { + if actor_id != state.player_red { + send_ephemeral( + ctx, + component, + "Seul le createur de la partie peut jouer contre le bot.", + ) + .await; + return true; + } + } else if actor_id != state.current_turn { + send_ephemeral(ctx, component, "Ce n'est pas ton tour.").await; + return true; + } + + if let Err(error) = apply_turn(&mut state, col, actor_id) { + send_ephemeral(ctx, component, error).await; + return true; + } + + if state.vs_bot && state.winner == 0 { + state.current_turn = 0; + apply_bot_turn(&mut state); + } + + let status = if state.winner == 0 { + "active" + } else { + "finished" + }; + let state_json = serde_json::to_string(&state).unwrap_or_else(|_| session.state_json.clone()); + let _ = db::update_game_session_state(&pool, session.id, &state_json, status).await; + + let color = theme_color(ctx).await; + let _ = component + .create_response( + &ctx.http, + CreateInteractionResponse::UpdateMessage( + CreateInteractionResponseMessage::new() + .embed(game_embed(session.id, &state, color)) + .components(game_components(session.id, &state)), + ), + ) + .await; + + true +} + +pub struct Puissance4Command; +pub static COMMAND_DESCRIPTOR: Puissance4Command = Puissance4Command; + +impl crate::commands::command_contract::CommandSpec for Puissance4Command { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + name: "puissance4", + category: "game", + params: "aucun", + description: "Lancer une partie de puissance4.", + examples: &["+puissance4"], + default_aliases: &["connect4", "p4"], + allow_in_dm: true, + default_permission: 0, + } + } +} diff --git a/src/commands/game/rickroll.rs b/src/commands/game/rickroll.rs new file mode 100644 index 0000000..7bd4e7c --- /dev/null +++ b/src/commands/game/rickroll.rs @@ -0,0 +1,43 @@ +use serenity::builder::{CreateActionRow, CreateButton, CreateEmbed, CreateMessage}; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::common::theme_color; + +pub async fn handle_rickroll(ctx: &Context, msg: &Message, _args: &[&str]) { + let url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"; + let embed = CreateEmbed::new() + .title("Rickroll") + .description("Never gonna give you up.") + .color(theme_color(ctx).await); + + let _ = msg + .channel_id + .send_message( + &ctx.http, + CreateMessage::new() + .embed(embed) + .components(vec![CreateActionRow::Buttons(vec![ + CreateButton::new_link(url).label("Ouvrir la video"), + ])]), + ) + .await; +} + +pub struct RickrollCommand; +pub static COMMAND_DESCRIPTOR: RickrollCommand = RickrollCommand; + +impl crate::commands::command_contract::CommandSpec for RickrollCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + name: "rickroll", + category: "game", + params: "aucun", + description: "Never gonna give you up.", + examples: &["+rickroll"], + default_aliases: &["rr"], + allow_in_dm: true, + default_permission: 0, + } + } +} diff --git a/src/commands/game/slot.rs b/src/commands/game/slot.rs new file mode 100644 index 0000000..a711c25 --- /dev/null +++ b/src/commands/game/slot.rs @@ -0,0 +1,54 @@ +use rand::seq::SliceRandom; +use serenity::builder::CreateEmbed; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::common::{send_embed, theme_color}; + +pub async fn handle_slot(ctx: &Context, msg: &Message, _args: &[&str]) { + let symbols = ["7", "BAR", "STAR", "BELL", "CHERRY"]; + let (a, b, c) = { + let mut rng = rand::thread_rng(); + ( + symbols.choose(&mut rng).copied().unwrap_or("7"), + symbols.choose(&mut rng).copied().unwrap_or("7"), + symbols.choose(&mut rng).copied().unwrap_or("7"), + ) + }; + + let result = if a == b && b == c { + "Jackpot" + } else if a == b || b == c || a == c { + "Presque" + } else { + "Perdu" + }; + + let embed = CreateEmbed::new() + .title("Slot") + .description(format!( + "[ {} | {} | {} ]\nResultat: **{}**", + a, b, c, result + )) + .color(theme_color(ctx).await); + + send_embed(ctx, msg, embed).await; +} + +pub struct SlotCommand; +pub static COMMAND_DESCRIPTOR: SlotCommand = SlotCommand; + +impl crate::commands::command_contract::CommandSpec for SlotCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + name: "slot", + category: "game", + params: "aucun", + description: "Jouer au jeu Slot.", + examples: &["+slot"], + default_aliases: &["machine"], + allow_in_dm: true, + default_permission: 0, + } + } +} diff --git a/src/commands/game/snake.rs b/src/commands/game/snake.rs new file mode 100644 index 0000000..db5f590 --- /dev/null +++ b/src/commands/game/snake.rs @@ -0,0 +1,52 @@ +use rand::seq::SliceRandom; +use serenity::builder::CreateEmbed; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::common::{send_embed, theme_color}; + +pub async fn handle_snake(ctx: &Context, msg: &Message, _args: &[&str]) { + let directions = ["haut", "bas", "gauche", "droite"]; + let foods = ["pomme", "banane", "fraise", "citron"]; + + let (direction, food) = { + let mut rng = rand::thread_rng(); + ( + directions.choose(&mut rng).copied().unwrap_or("haut"), + foods.choose(&mut rng).copied().unwrap_or("pomme"), + ) + }; + + let embed = CreateEmbed::new() + .title("Snake") + .description(format!( + "Partie lancee. Direction conseillee: **{}**.\nObjectif courant: attraper une **{}**.", + direction, food + )) + .field( + "Commande rapide", + "Relance `+snake` pour un nouveau round.", + false, + ) + .color(theme_color(ctx).await); + + send_embed(ctx, msg, embed).await; +} + +pub struct SnakeCommand; +pub static COMMAND_DESCRIPTOR: SnakeCommand = SnakeCommand; + +impl crate::commands::command_contract::CommandSpec for SnakeCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + name: "snake", + category: "game", + params: "aucun", + description: "Lancer une partie de snake.", + examples: &["+snake"], + default_aliases: &["snk"], + allow_in_dm: true, + default_permission: 0, + } + } +} diff --git a/src/commands/game/unmarry.rs b/src/commands/game/unmarry.rs new file mode 100644 index 0000000..b76d1ed --- /dev/null +++ b/src/commands/game/unmarry.rs @@ -0,0 +1,39 @@ +use serenity::builder::CreateEmbed; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::common::{mention_user, send_embed, theme_color}; + +pub async fn handle_unmarry(ctx: &Context, msg: &Message, _args: &[&str]) { + let author = mention_user(msg.author.id); + let target = msg + .mentions + .first() + .map(|user| mention_user(user.id)) + .unwrap_or_else(|| "ton partenaire imaginaire".to_string()); + + let embed = CreateEmbed::new() + .title("Unmarry") + .description(format!("{} a dissous le mariage avec {}.", author, target)) + .color(theme_color(ctx).await); + + send_embed(ctx, msg, embed).await; +} + +pub struct UnmarryCommand; +pub static COMMAND_DESCRIPTOR: UnmarryCommand = UnmarryCommand; + +impl crate::commands::command_contract::CommandSpec for UnmarryCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + name: "unmarry", + category: "game", + params: "[@user]", + description: "Dissoudre un mariage.", + examples: &["+unmarry", "+unmarry @Pseudo"], + default_aliases: &["divorce"], + allow_in_dm: true, + default_permission: 0, + } + } +} diff --git a/src/commands/game/wordle.rs b/src/commands/game/wordle.rs new file mode 100644 index 0000000..5ad7c4b --- /dev/null +++ b/src/commands/game/wordle.rs @@ -0,0 +1,44 @@ +use rand::seq::SliceRandom; +use serenity::builder::CreateEmbed; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::common::{send_embed, theme_color}; + +pub async fn handle_wordle(ctx: &Context, msg: &Message, _args: &[&str]) { + let words = ["pomme", "ombre", "salon", "banjo", "pixel", "vocal"]; + + let secret = { + let mut rng = rand::thread_rng(); + words.choose(&mut rng).copied().unwrap_or("pomme") + }; + let hint = format!("{}{}", &secret[0..1], "_ _ _ _"); + + let embed = CreateEmbed::new() + .title("Wordle") + .description(format!( + "Mot secret de 5 lettres initialise.\nIndice: **{}**\n\nPropose un mot avec `+wordle ` (version libre).", + hint + )) + .color(theme_color(ctx).await); + + send_embed(ctx, msg, embed).await; +} + +pub struct WordleCommand; +pub static COMMAND_DESCRIPTOR: WordleCommand = WordleCommand; + +impl crate::commands::command_contract::CommandSpec for WordleCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + name: "wordle", + category: "game", + params: "aucun", + description: "Jouer a Wordle.", + examples: &["+wordle"], + default_aliases: &["wd"], + allow_in_dm: true, + default_permission: 0, + } + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index a15bc00..68abb4e 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -74,6 +74,8 @@ pub mod bringall; pub mod button; #[path = "fun/calc.rs"] pub mod calc; +#[path = "game/catsay.rs"] +pub mod catsay; #[path = "botconfig/change.rs"] pub mod change; #[path = "botconfig/changeall.rs"] @@ -84,8 +86,12 @@ pub mod changereset; pub mod channel; #[path = "fun/choose.rs"] pub mod choose; +#[path = "game/christmas.rs"] +pub mod christmas; #[path = "ticket/claim.rs"] pub mod claim; +#[path = "game/claque.rs"] +pub mod claque; #[path = "mod/cleanup.rs"] pub mod cleanup; #[path = "mod/clearallsanctions.rs"] @@ -124,12 +130,16 @@ pub mod del_sanction; pub mod delperm; #[path = "roles/delrole.rs"] pub mod delrole; +#[path = "game/demineur.rs"] +pub mod demineur; #[path = "roles/derank.rs"] pub mod derank; #[path = "owner/discussion.rs"] pub mod discussion; #[path = "botconfig/dnd.rs"] pub mod dnd; +#[path = "game/eightball.rs"] +pub mod eightball; #[path = "fun/embed.rs"] pub mod embed; #[path = "fun/emoji.rs"] @@ -138,8 +148,22 @@ pub mod emoji; pub mod end; #[path = "event/endgiveaway.rs"] pub mod endgiveaway; +#[path = "game/epicgamer.rs"] +pub mod epicgamer; +#[path = "game/fasttype.rs"] +pub mod fasttype; +#[path = "game/findemoji.rs"] +pub mod findemoji; +#[path = "game/flood.rs"] +pub mod flood; +#[path = "game/g2048.rs"] +pub mod g2048; #[path = "event/giveaway.rs"] pub mod giveaway; +#[path = "game/guesspokemon.rs"] +pub mod guesspokemon; +#[path = "game/halloween.rs"] +pub mod halloween; #[path = "perms/help.rs"] pub mod help; #[path = "perms/helpsetting.rs"] @@ -162,6 +186,8 @@ pub mod invitereset; pub mod join; #[path = "mod/kick.rs"] pub mod kick; +#[path = "game/kiss.rs"] +pub mod kiss; #[path = "owner/leave.rs"] pub mod leave; #[path = "config/leavesettings.rs"] @@ -182,6 +208,8 @@ pub mod logs_command_helpers; pub mod logs_service; #[path = "botconfig/mainprefix.rs"] pub mod mainprefix; +#[path = "game/marry.rs"] +pub mod marry; #[path = "roles/massiverole.rs"] pub mod massiverole; #[path = "info/member.rs"] @@ -196,6 +224,8 @@ pub mod moderation_sanction_helpers; pub mod moderation_tools; #[path = "config/modlog.rs"] pub mod modlog; +#[path = "game/morpion.rs"] +pub mod morpion; #[path = "owner/mp.rs"] pub mod mp; #[path = "owner/mpdelete.rs"] @@ -224,12 +254,16 @@ pub mod nolog; pub mod online; #[path = "owner/owner.rs"] pub mod owner; +#[path = "game/pendu.rs"] +pub mod pendu; #[path = "perms/perms.rs"] pub mod perms; #[path = "../utils/perms_helpers.rs"] pub mod perms_helpers; #[path = "../utils/perms_service.rs"] pub mod perms_service; +#[path = "game/pfc.rs"] +pub mod pfc; #[path = "info/pic.rs"] pub mod pic; #[path = "automation/piconly.rs"] @@ -246,6 +280,8 @@ pub mod playto; pub mod prefix; #[path = "channel/public.rs"] pub mod public; +#[path = "game/puissance4.rs"] +pub mod puissance4; #[path = "mod/punish.rs"] pub mod punish; #[path = "mod/punishadd.rs"] @@ -268,6 +304,8 @@ pub mod renew; pub mod reroll; #[path = "security/resetantiraide.rs"] pub mod resetantiraide; +#[path = "game/rickroll.rs"] +pub mod rickroll; #[path = "info/role.rs"] pub mod role; #[path = "config/rolelog.rs"] @@ -312,8 +350,12 @@ pub mod setprofil; pub mod shadowbot; #[path = "info/showpics.rs"] pub mod showpics; +#[path = "game/slot.rs"] +pub mod slot; #[path = "channel/slowmode.rs"] pub mod slowmode; +#[path = "game/snake.rs"] +pub mod snake; #[path = "fun/snipe.rs"] pub mod snipe; #[path = "security/spam.rs"] @@ -368,6 +410,8 @@ pub mod unhideall; pub mod unlock; #[path = "channel/unlockall.rs"] pub mod unlockall; +#[path = "game/unmarry.rs"] +pub mod unmarry; #[path = "roles/unmassiverole.rs"] pub mod unmassiverole; #[path = "mod/unmute.rs"] @@ -394,6 +438,8 @@ pub mod voicemove; pub mod warn; #[path = "botconfig/watch.rs"] pub mod watch; +#[path = "game/wordle.rs"] +pub mod wordle; pub fn all_command_metadata() -> Vec { vec![ @@ -443,6 +489,29 @@ pub fn all_command_metadata() -> Vec { end::COMMAND_DESCRIPTOR.metadata(), reroll::COMMAND_DESCRIPTOR.metadata(), choose::COMMAND_DESCRIPTOR.metadata(), + g2048::COMMAND_DESCRIPTOR.metadata(), + snake::COMMAND_DESCRIPTOR.metadata(), + unmarry::COMMAND_DESCRIPTOR.metadata(), + pendu::COMMAND_DESCRIPTOR.metadata(), + pfc::COMMAND_DESCRIPTOR.metadata(), + flood::COMMAND_DESCRIPTOR.metadata(), + puissance4::COMMAND_DESCRIPTOR.metadata(), + morpion::COMMAND_DESCRIPTOR.metadata(), + epicgamer::COMMAND_DESCRIPTOR.metadata(), + demineur::COMMAND_DESCRIPTOR.metadata(), + catsay::COMMAND_DESCRIPTOR.metadata(), + claque::COMMAND_DESCRIPTOR.metadata(), + slot::COMMAND_DESCRIPTOR.metadata(), + fasttype::COMMAND_DESCRIPTOR.metadata(), + rickroll::COMMAND_DESCRIPTOR.metadata(), + kiss::COMMAND_DESCRIPTOR.metadata(), + wordle::COMMAND_DESCRIPTOR.metadata(), + findemoji::COMMAND_DESCRIPTOR.metadata(), + marry::COMMAND_DESCRIPTOR.metadata(), + guesspokemon::COMMAND_DESCRIPTOR.metadata(), + eightball::COMMAND_DESCRIPTOR.metadata(), + halloween::COMMAND_DESCRIPTOR.metadata(), + christmas::COMMAND_DESCRIPTOR.metadata(), embed::COMMAND_DESCRIPTOR.metadata(), clear_messages::COMMAND_DESCRIPTOR.metadata(), clear_limit::COMMAND_DESCRIPTOR.metadata(), diff --git a/src/commands/perms/help.rs b/src/commands/perms/help.rs index cba264f..9778e30 100644 --- a/src/commands/perms/help.rs +++ b/src/commands/perms/help.rs @@ -116,6 +116,11 @@ const HELP_PAGES: &[HelpPage] = &[ title: "Outils", description: "Giveaways, utilitaires, embeds et automatisations de contenu.", }, + HelpPage { + key: "game", + title: "Game", + description: "Jeux et commandes ludiques.", + }, HelpPage { key: "bot", title: "Bot & Présence", @@ -177,6 +182,7 @@ fn help_page_for_command( "roles" => "roles", "salons_vocal" => "salons_vocal", "outils" => "outils", + "game" => "game", "bot" => "bot", "administration" => "administration", "permissions" => "permissions", @@ -238,6 +244,7 @@ fn help_page_matches_input(page: &HelpPage, input: &str) -> bool { "roles" => &["role", "roles"][..], "salons_vocal" => &["salon", "salons", "vocal", "voice", "channels"][..], "outils" => &["utilitaires", "tools", "giveaway"][..], + "game" => &["jeu", "jeux", "games", "fun"][..], "bot" => &["profil", "presence", "activite", "activity"][..], "administration" => &["admin", "admins"][..], "permissions" => &["permission", "perms", "aide", "help"][..], diff --git a/src/db.rs b/src/db.rs index aed0c57..7d36b63 100644 --- a/src/db.rs +++ b/src/db.rs @@ -250,6 +250,23 @@ pub struct PunishRule { pub updated_at: DateTime, } +#[derive(Debug, Clone, sqlx::FromRow)] +#[allow(dead_code)] +pub struct GameSession { + pub id: i64, + pub bot_id: i64, + pub guild_id: Option, + pub channel_id: i64, + pub message_id: Option, + pub game_type: String, + pub owner_id: i64, + pub participants_json: String, + pub state_json: String, + pub status: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} + pub async fn create_pool(database_url: &str) -> Result { PgPoolOptions::new() .max_connections(10) @@ -1263,6 +1280,36 @@ pub async fn init_schema(pool: &PgPool) -> Result<(), sqlx::Error> { .execute(pool) .await?; + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS bot_game_sessions ( + id BIGSERIAL PRIMARY KEY, + bot_id BIGINT NOT NULL, + guild_id BIGINT NULL, + channel_id BIGINT NOT NULL, + message_id BIGINT NULL, + game_type TEXT NOT NULL, + owner_id BIGINT NOT NULL, + participants_json TEXT NOT NULL DEFAULT '[]', + state_json TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + "#, + ) + .execute(pool) + .await?; + + sqlx::query( + r#" + CREATE INDEX IF NOT EXISTS idx_bot_game_sessions_lookup + ON bot_game_sessions (bot_id, game_type, status, updated_at DESC); + "#, + ) + .execute(pool) + .await?; + Ok(()) } @@ -4606,3 +4653,128 @@ pub async fn upsert_last_punish_triggered_at( Ok(()) } + +// ========== GAME SESSIONS FUNCTIONS ========== + +pub async fn create_game_session( + pool: &PgPool, + bot_id: i64, + guild_id: Option, + channel_id: i64, + owner_id: i64, + game_type: &str, + participants_json: &str, + state_json: &str, +) -> Result { + let session = sqlx::query_as::<_, GameSession>( + r#" + INSERT INTO bot_game_sessions ( + bot_id, + guild_id, + channel_id, + owner_id, + game_type, + participants_json, + state_json, + status + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, 'active') + RETURNING *; + "#, + ) + .bind(bot_id) + .bind(guild_id) + .bind(channel_id) + .bind(owner_id) + .bind(game_type) + .bind(participants_json) + .bind(state_json) + .fetch_one(pool) + .await?; + + Ok(session) +} + +pub async fn set_game_session_message( + pool: &PgPool, + session_id: i64, + message_id: i64, +) -> Result<(), sqlx::Error> { + sqlx::query( + r#" + UPDATE bot_game_sessions + SET message_id = $1, updated_at = NOW() + WHERE id = $2; + "#, + ) + .bind(message_id) + .bind(session_id) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn get_game_session( + pool: &PgPool, + session_id: i64, +) -> Result, sqlx::Error> { + let session = sqlx::query_as::<_, GameSession>( + r#" + SELECT * + FROM bot_game_sessions + WHERE id = $1 + LIMIT 1; + "#, + ) + .bind(session_id) + .fetch_optional(pool) + .await?; + + Ok(session) +} + +pub async fn update_game_session_state( + pool: &PgPool, + session_id: i64, + state_json: &str, + status: &str, +) -> Result, sqlx::Error> { + let session = sqlx::query_as::<_, GameSession>( + r#" + UPDATE bot_game_sessions + SET state_json = $1, status = $2, updated_at = NOW() + WHERE id = $3 + RETURNING *; + "#, + ) + .bind(state_json) + .bind(status) + .bind(session_id) + .fetch_optional(pool) + .await?; + + Ok(session) +} + +#[allow(dead_code)] +pub async fn update_game_session_participants( + pool: &PgPool, + session_id: i64, + participants_json: &str, +) -> Result, sqlx::Error> { + let session = sqlx::query_as::<_, GameSession>( + r#" + UPDATE bot_game_sessions + SET participants_json = $1, updated_at = NOW() + WHERE id = $2 + RETURNING *; + "#, + ) + .bind(participants_json) + .bind(session_id) + .fetch_optional(pool) + .await?; + + Ok(session) +} diff --git a/src/events/interaction_create.rs b/src/events/interaction_create.rs index 6552077..f60981a 100644 --- a/src/events/interaction_create.rs +++ b/src/events/interaction_create.rs @@ -2,8 +2,8 @@ use serenity::model::prelude::*; use serenity::prelude::*; use crate::commands::{ - advanced_tools, ancien, autoconfiglog, boostembed, help, helpsetting, mp, perms_service, - rolemenu, suggestion, tempvoc, ticket, viewlogs, + advanced_tools, ancien, autoconfiglog, boostembed, g2048, help, helpsetting, morpion, mp, + perms_service, puissance4, rolemenu, suggestion, tempvoc, ticket, viewlogs, }; pub async fn handle_interaction_create(ctx: &Context, interaction: &Interaction) { @@ -42,6 +42,18 @@ pub async fn handle_interaction_create(ctx: &Context, interaction: &Interaction) return; } + if morpion::handle_component_interaction(ctx, component).await { + return; + } + + if puissance4::handle_component_interaction(ctx, component).await { + return; + } + + if g2048::handle_component_interaction(ctx, component).await { + return; + } + if help::handle_help_component(ctx, component).await { return; } diff --git a/src/events/message.rs b/src/events/message.rs index 1ce0047..3ca1ff5 100644 --- a/src/events/message.rs +++ b/src/events/message.rs @@ -9,22 +9,25 @@ use crate::commands::{ addinvite, addrole, alias, ancien, antilink, antimassmention, antiraideautoconfig, antispam, autobackup, autoconfiglog, autopublish, autopublishoff, autopublishon, autoreact, backup, badwords, ban, banlist, banner, bl, blinfo, boostembed, boosters, boostlog, bringall, button, - calc, change, changeall, changereset, channel, choose, claim, cleanup, clear_all_sanctions, - clear_badwords, clear_bl, clear_limit, clear_messages, clear_owners, clear_perms, - clear_sanctions, close, cmute, compet, create, del_sanction, delperm, delrole, derank, - discussion, dnd, embed, emoji, end, endgiveaway, giveaway, help, helpsetting, hide, hideall, - idle, invisible, invite, inviteboard, invitereset, join, kick, leave, leave_settings, link, - listen, loading, lock, lockall, mainprefix, massiverole, member, messagelog, modlog, mp, - mpdelete, mpsent, mpsettings, mute, mutelist, muterole, newsticker, noderank, noderankadd, - noderankdel, nolog, online, owner, perms, pic, piconly, piconlyadd, piconlydel, ping, playto, - prefix, public, punish, punishadd, punishdel, punishsetup, raidlog, removeinvite, rename, - renew, reroll, resetantiraide, role, rolelog, rolemembers, rolemenu, sanctions, say, + calc, catsay, change, changeall, changereset, channel, choose, christmas, claim, claque, + cleanup, clear_all_sanctions, clear_badwords, clear_bl, clear_limit, clear_messages, + clear_owners, clear_perms, clear_sanctions, close, cmute, compet, create, del_sanction, + delperm, delrole, demineur, derank, discussion, dnd, eightball, embed, emoji, end, endgiveaway, + epicgamer, fasttype, findemoji, flood, g2048, giveaway, guesspokemon, halloween, help, + helpsetting, hide, hideall, idle, invisible, invite, inviteboard, invitereset, join, kick, + kiss, leave, leave_settings, link, listen, loading, lock, lockall, mainprefix, marry, + massiverole, member, messagelog, modlog, morpion, mp, mpdelete, mpsent, mpsettings, mute, + mutelist, muterole, newsticker, noderank, noderankadd, noderankdel, nolog, online, owner, + pendu, perms, pfc, pic, piconly, piconlyadd, piconlydel, ping, playto, prefix, public, + puissance4, punish, punishadd, punishdel, punishsetup, raidlog, removeinvite, rename, renew, + reroll, resetantiraide, rickroll, role, rolelog, rolemembers, rolemenu, sanctions, say, serverbanner, serverinfo, serverlist, serverpic, set_boostembed, set_modlogs, set_muterole, - setbanner, setname, setperm, setpic, setprofil, shadowbot, showpics, slowmode, snipe, spam, - stream, strikes, suggestion, suggestionsettings, sync, tempban, tempcmute, tempmute, temprole, - tempvoc, tempvoc_cmd, theme, ticket, ticket_member, tickets, timeout, unalias, unban, unbanall, - unbl, uncmute, unhide, unhideall, unlock, unlockall, unmassiverole, unmute, unmuteall, unowner, - untemprole, user, viewlogs, vocinfo, voicekick, voicelog, voicemove, warn, watch, + setbanner, setname, setperm, setpic, setprofil, shadowbot, showpics, slot, slowmode, snake, + snipe, spam, stream, strikes, suggestion, suggestionsettings, sync, tempban, tempcmute, + tempmute, temprole, tempvoc, tempvoc_cmd, theme, ticket, ticket_member, tickets, timeout, + unalias, unban, unbanall, unbl, uncmute, unhide, unhideall, unlock, unlockall, unmarry, + unmassiverole, unmute, unmuteall, unowner, untemprole, user, viewlogs, vocinfo, voicekick, + voicelog, voicemove, warn, watch, wordle, }; use crate::commands::{alladmins, allbots, allperms, botadmins}; use crate::db::{DbPoolKey, upsert_message_observed}; @@ -261,6 +264,29 @@ pub async fn handle_message(ctx: &Context, msg: &Message) { "end" => end::handle_end(ctx, msg, &args).await, "reroll" => reroll::handle_reroll(ctx, msg, &args).await, "choose" => choose::handle_choose(ctx, msg, &args).await, + "2048" => g2048::handle_2048(ctx, msg, &args).await, + "snake" => snake::handle_snake(ctx, msg, &args).await, + "unmarry" => unmarry::handle_unmarry(ctx, msg, &args).await, + "pendu" => pendu::handle_pendu(ctx, msg, &args).await, + "pfc" => pfc::handle_pfc(ctx, msg, &args).await, + "flood" => flood::handle_flood(ctx, msg, &args).await, + "puissance4" => puissance4::handle_puissance4(ctx, msg, &args).await, + "morpion" => morpion::handle_morpion(ctx, msg, &args).await, + "epicgamer" => epicgamer::handle_epicgamer(ctx, msg, &args).await, + "demineur" => demineur::handle_demineur(ctx, msg, &args).await, + "catsay" => catsay::handle_catsay(ctx, msg, &args).await, + "claque" => claque::handle_claque(ctx, msg, &args).await, + "slot" => slot::handle_slot(ctx, msg, &args).await, + "fasttype" => fasttype::handle_fasttype(ctx, msg, &args).await, + "rickroll" => rickroll::handle_rickroll(ctx, msg, &args).await, + "kiss" => kiss::handle_kiss(ctx, msg, &args).await, + "wordle" => wordle::handle_wordle(ctx, msg, &args).await, + "findemoji" => findemoji::handle_findemoji(ctx, msg, &args).await, + "marry" => marry::handle_marry(ctx, msg, &args).await, + "guesspokemon" => guesspokemon::handle_guesspokemon(ctx, msg, &args).await, + "8ball" => eightball::handle_eightball(ctx, msg, &args).await, + "halloween" => halloween::handle_halloween(ctx, msg, &args).await, + "christmas" => christmas::handle_christmas(ctx, msg, &args).await, "embed" => embed::handle_embed(ctx, msg, &args).await, "backup" => backup::handle_backup(ctx, msg, &args).await, "autobackup" => autobackup::handle_autobackup(ctx, msg, &args).await,