Files
yourchat2/src/commands.rs
Torsten Schulz (local) 0b91b94ae1 Initialisiere yourchat2 als eigenständigen Rust-Chatdienst und portiere die Kernfunktionen aus der Altanwendung.
Die Implementierung enthält modulare Command-/State-/DB-Strukturen, DB-basierte Authentifizierung inkl. Rechte- und Raumzugriffsprüfung sowie kompatible Chat- und Dice-Commands.

Made-with: Cursor
2026-03-04 17:04:41 +01:00

1022 lines
31 KiB
Rust

use crate::types::{ChatState, ClientId, Command, RoomMeta, ServerConfig};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use uuid::Uuid;
use crate::db;
use crate::state;
pub async fn process_text_command(
client_id: ClientId,
raw: &str,
state: Arc<RwLock<ChatState>>,
config: Arc<ServerConfig>,
) {
let trimmed = raw.trim();
if trimmed.is_empty() {
return;
}
match serde_json::from_str::<Command>(trimmed) {
Ok(command) => {
handle_command(client_id, command, state, config).await;
}
Err(err) => {
state::send_to_client(
client_id,
state,
json!({"type":"error","message": format!("invalid_json: {err}")}),
)
.await;
}
}
}
pub async fn handle_command(
client_id: ClientId,
command: Command,
state: Arc<RwLock<ChatState>>,
config: Arc<ServerConfig>,
) {
let mut current = command;
loop {
match command_name(&current).as_str() {
"init" => {
handle_init_command(client_id, &current, Arc::clone(&state), Arc::clone(&config)).await;
return;
}
"join" => {
handle_join_command(client_id, &current, Arc::clone(&state), Arc::clone(&config)).await;
return;
}
"message" => {
if let Some(next_command) =
handle_message_command(client_id, &current, Arc::clone(&state)).await
{
current = next_command;
continue;
}
return;
}
"scream" => {
handle_scream_command(client_id, &current, Arc::clone(&state)).await;
return;
}
"do" => {
handle_do_command(client_id, &current, Arc::clone(&state)).await;
return;
}
"dice" | "roll" => {
handle_dice_command(client_id, &current, Arc::clone(&state)).await;
return;
}
"color" => {
handle_color_command(client_id, &current, Arc::clone(&state), Arc::clone(&config)).await;
return;
}
"rooms" | "list_rooms" => {
handle_rooms_command(client_id, &current, Arc::clone(&state)).await;
return;
}
"userlist" => {
handle_userlist_command(client_id, &current, Arc::clone(&state)).await;
return;
}
"ping" => {
state::send_to_client(client_id, Arc::clone(&state), json!({"type":"pong"})).await;
return;
}
"start_dice_game" => {
handle_start_dice_game_command(client_id, &current, Arc::clone(&state)).await;
return;
}
"end_dice_game" => {
handle_end_dice_game_command(client_id, &current, Arc::clone(&state)).await;
return;
}
"reload_rooms" => {
handle_reload_rooms_command(client_id, &current, Arc::clone(&state), Arc::clone(&config)).await;
return;
}
_ => {
handle_unknown_command(client_id, &current, Arc::clone(&state)).await;
return;
}
}
}
}
fn command_name(command: &Command) -> String {
match &command.cmd_type {
Value::String(s) => s.to_lowercase(),
Value::Number(n) => n.to_string(),
_ => String::new(),
}
}
async fn send_error(client_id: ClientId, state: Arc<RwLock<ChatState>>, message: &str) {
state::send_to_client(client_id, state, json!({"type":"error","message": message})).await;
}
async fn handle_init_command(
client_id: ClientId,
command: &Command,
state: Arc<RwLock<ChatState>>,
config: Arc<ServerConfig>,
) {
let Some(raw_name) = command.name.clone().or(command.user_name.clone()) else {
send_error(client_id, Arc::clone(&state), "missing_name").await;
return;
};
let requested_user_name = raw_name.trim().to_string();
if !db::is_valid_username(&requested_user_name) {
send_error(client_id, Arc::clone(&state), "invalid_username").await;
return;
}
if !db::is_allowed_user(&requested_user_name, &config) {
send_error(client_id, Arc::clone(&state), "user_not_allowed").await;
return;
}
let profile = match db::load_user_profile(&requested_user_name, &config).await {
Ok(profile) => profile,
Err(error_message) => {
send_error(client_id, Arc::clone(&state), error_message).await;
return;
}
};
let room_name = normalize_room_name(command.room.as_deref().unwrap_or("lobby"));
let room_password = command.password.clone().unwrap_or_default();
let (token, user_name) = {
let mut guard = state.write().await;
if guard.logged_in_names.contains(&requested_user_name)
&& guard
.clients
.iter()
.any(|(id, c)| *id != client_id && c.logged_in && c.user_name == requested_user_name)
{
drop(guard);
send_error(client_id, Arc::clone(&state), "loggedin").await;
return;
}
if !room_access_allowed(
guard.room_meta.get(&room_name),
&profile.display_name,
profile.falukant_user_id,
profile.age,
&room_password,
) {
drop(guard);
send_error(client_id, Arc::clone(&state), "room_not_found_or_join_failed").await;
return;
}
let (old_room, old_name, was_logged_in, user_name, token, new_token) = {
let Some(client) = guard.clients.get_mut(&client_id) else {
return;
};
let old_room = client.room.clone();
let old_name = client.user_name.clone();
let was_logged_in = client.logged_in;
client.user_name = profile.display_name.clone();
client.color = profile.color.clone();
client.falukant_user_id = profile.falukant_user_id;
client.chat_user_id = profile.chat_user_id;
client.age = profile.age;
client.rights = profile.rights.clone();
client.logged_in = true;
client.room = room_name.clone();
let mut new_token = None;
if client.token.is_none() {
let generated = Uuid::new_v4().to_string();
client.token = Some(generated.clone());
new_token = Some(generated);
}
(
old_room,
old_name,
was_logged_in,
client.user_name.clone(),
client.token.clone().unwrap_or_default(),
new_token,
)
};
if let Some(generated) = new_token {
guard.tokens.insert(generated, client_id);
}
if was_logged_in {
guard.logged_in_names.remove(&old_name);
}
guard.logged_in_names.insert(user_name.clone());
if !old_room.is_empty() {
if let Some(members) = guard.rooms.get_mut(&old_room) {
members.remove(&client_id);
}
}
guard.rooms.entry(room_name.clone()).or_default().insert(client_id);
(token, user_name)
};
state::send_to_client(
client_id,
Arc::clone(&state),
json!({"type":1, "message": token}),
)
.await;
state::send_room_list(client_id, Arc::clone(&state)).await;
state::send_to_client(
client_id,
Arc::clone(&state),
json!({"type":5, "message":"room_entered", "to": room_name}),
)
.await;
state::broadcast_room(
&room_name,
Arc::clone(&state),
json!({"type":"system","message": format!("{user_name} joined {room_name}")}),
Some(client_id),
)
.await;
}
async fn handle_join_command(
client_id: ClientId,
command: &Command,
state: Arc<RwLock<ChatState>>,
_config: Arc<ServerConfig>,
) {
if !state::authorize(client_id, command, Arc::clone(&state)).await {
return;
}
let room = normalize_room_name(command.room.as_deref().or(command.name.as_deref()).unwrap_or("lobby"));
let password = command.password.clone().unwrap_or_default();
let (from_room, user_name) = {
let mut guard = state.write().await;
let (name_for_check, falukant_user_id, age) = {
let Some(client) = guard.clients.get(&client_id) else {
return;
};
(client.user_name.clone(), client.falukant_user_id, client.age)
};
if !room_access_allowed(
guard.room_meta.get(&room),
&name_for_check,
falukant_user_id,
age,
&password,
) {
drop(guard);
send_error(client_id, Arc::clone(&state), "room_not_found_or_join_failed").await;
return;
}
let Some(client) = guard.clients.get_mut(&client_id) else {
return;
};
let from_room = client.room.clone();
let user_name = client.user_name.clone();
client.room = room.clone();
if !from_room.is_empty() {
if let Some(members) = guard.rooms.get_mut(&from_room) {
members.remove(&client_id);
}
}
guard.rooms.entry(room.clone()).or_default().insert(client_id);
(from_room, user_name)
};
if !from_room.is_empty() && from_room != room {
state::broadcast_room(
&from_room,
Arc::clone(&state),
json!({"type":"system","message": format!("{user_name} left {from_room}")}),
Some(client_id),
)
.await;
}
state::send_to_client(
client_id,
Arc::clone(&state),
json!({"type":5, "message":"room_entered", "to": room}),
)
.await;
state::send_room_list(client_id, Arc::clone(&state)).await;
}
async fn handle_message_command(
client_id: ClientId,
command: &Command,
state: Arc<RwLock<ChatState>>,
) -> Option<Command> {
if !state::authorize(client_id, command, Arc::clone(&state)).await {
return None;
}
let text = command.message.clone().unwrap_or_default();
if text.trim().is_empty() {
return None;
}
if let Some(cmd) = command_from_chat_line(&text, command.token.clone()) {
return Some(cmd);
}
let (room, user_name, color) = {
let guard = state.read().await;
let Some(client) = guard.clients.get(&client_id) else {
return None;
};
(client.room.clone(), client.user_name.clone(), client.color.clone())
};
if room.is_empty() {
return None;
}
state::broadcast_room(
&room,
Arc::clone(&state),
json!({"type":"message", "userName": user_name, "message": text, "color": color}),
None,
)
.await;
None
}
async fn handle_scream_command(client_id: ClientId, command: &Command, state: Arc<RwLock<ChatState>>) {
if !state::authorize(client_id, command, Arc::clone(&state)).await {
return;
}
let text = command.message.clone().unwrap_or_default();
if text.trim().is_empty() {
return;
}
let (user_name, color) = {
let guard = state.read().await;
let Some(client) = guard.clients.get(&client_id) else {
return;
};
(client.user_name.clone(), client.color.clone())
};
state::broadcast_all(
Arc::clone(&state),
json!({"type":6, "userName": user_name, "message": text, "color": color}),
)
.await;
}
async fn handle_do_command(client_id: ClientId, command: &Command, state: Arc<RwLock<ChatState>>) {
if !state::authorize(client_id, command, Arc::clone(&state)).await {
return;
}
let text = command
.message
.clone()
.or_else(|| string_from_value(command.value.as_ref()))
.unwrap_or_default();
if text.trim().is_empty() {
return;
}
let target = command.to.clone();
let (room, user_name, color) = {
let guard = state.read().await;
let Some(client) = guard.clients.get(&client_id) else {
return;
};
(
client.room.clone(),
client.user_name.clone(),
client.color.clone().unwrap_or_else(|| "#000000".to_string()),
)
};
if room.is_empty() {
return;
}
if let Some(to_name) = target {
state::broadcast_room(
&room,
Arc::clone(&state),
json!({
"type": 7,
"message": {"tr":"user_action","action": text,"to": to_name},
"userName": user_name,
"color": color
}),
None,
)
.await;
} else {
state::broadcast_room(
&room,
Arc::clone(&state),
json!({"type":"system", "message": format!("* {} {}", user_name, text)}),
None,
)
.await;
}
}
async fn handle_dice_command(client_id: ClientId, command: &Command, state: Arc<RwLock<ChatState>>) {
if !state::authorize(client_id, command, Arc::clone(&state)).await {
return;
}
let expr = command
.message
.clone()
.or_else(|| string_from_value(command.value.as_ref()))
.unwrap_or_else(|| "1d6".to_string());
let numeric_roll = i32_from_command_value(command).or_else(|| parse_roll_value(&expr));
let (room, user_name, color) = {
let guard = state.read().await;
let Some(client) = guard.clients.get(&client_id) else {
return;
};
(
client.room.clone(),
client.user_name.clone(),
client.color.clone().unwrap_or_else(|| "#000000".to_string()),
)
};
if room.is_empty() {
return;
}
let mut game_events: Vec<Value> = Vec::new();
let mut invalid_roll = false;
let mut already_rolled = false;
let mut round_snapshot: Option<(i32, HashMap<ClientId, i32>)> = None;
let mut total_snapshot: Option<HashMap<ClientId, i32>> = None;
let mut next_round_to_start: Option<i32> = None;
let mut game_ended = false;
{
let mut guard = state.write().await;
let member_count = guard.rooms.get(&room).map(|m| m.len()).unwrap_or(0);
if let Some(game) = guard.dice_games.get_mut(&room) {
if game.running {
if let Some(rolled) = numeric_roll {
if !(1..=6).contains(&rolled) {
invalid_roll = true;
} else if game.rolled_this_round.contains_key(&client_id) {
already_rolled = true;
} else {
game.rolled_this_round.insert(client_id, rolled);
*game.total_scores.entry(client_id).or_insert(0) += rolled;
game_events.push(json!({
"type": 8,
"message": {"tr":"dice_rolled","round": game.current_round,"value": rolled},
"userName": user_name,
"color": color
}));
if member_count > 0 && game.rolled_this_round.len() >= member_count {
round_snapshot = Some((game.current_round, game.rolled_this_round.clone()));
if game.current_round >= game.total_rounds {
total_snapshot = Some(game.total_scores.clone());
game.running = false;
game_ended = true;
} else {
game.current_round += 1;
game.rolled_this_round.clear();
next_round_to_start = Some(game.current_round);
}
}
}
} else {
invalid_roll = true;
}
}
}
}
if invalid_roll {
send_error(client_id, Arc::clone(&state), "invalid_dice_value").await;
return;
}
if already_rolled {
send_error(client_id, Arc::clone(&state), "dice_already_done").await;
return;
}
if let Some((round_number, rolled_values)) = round_snapshot {
let guard = state.read().await;
let mut round_results: Vec<Value> = Vec::new();
for (uid, value) in rolled_values {
if let Some(c) = guard.clients.get(&uid) {
round_results.push(json!({"userName": c.user_name, "value": value}));
}
}
game_events.push(json!({
"type": 8,
"message": {"tr":"dice_round_ended","round": round_number,"results": round_results}
}));
}
if let Some(total_scores) = total_snapshot {
let guard = state.read().await;
let mut final_results: Vec<Value> = Vec::new();
for (uid, score) in total_scores {
if let Some(c) = guard.clients.get(&uid) {
final_results.push(json!({"userName": c.user_name, "score": score}));
}
}
game_events.push(json!({
"type": 8,
"message": {"tr":"dice_game_results","results": final_results}
}));
}
if let Some(next_round) = next_round_to_start {
game_events.push(json!({"type": 8, "message": {"tr":"dice_round_started","round": next_round}}));
}
if game_ended {
game_events.push(json!({"type": 8, "message": {"tr":"dice_game_ended"}}));
}
if !game_events.is_empty() {
for event in game_events {
state::broadcast_room(&room, Arc::clone(&state), event, None).await;
}
} else {
state::broadcast_room(
&room,
Arc::clone(&state),
json!({"type":"system","message": format!("{user_name} rolled {expr}")}),
None,
)
.await;
}
}
async fn handle_color_command(
client_id: ClientId,
command: &Command,
state: Arc<RwLock<ChatState>>,
config: Arc<ServerConfig>,
) {
if !state::authorize(client_id, command, Arc::clone(&state)).await {
return;
}
let Some(mut color) = command
.message
.clone()
.or_else(|| string_from_value(command.value.as_ref()))
else {
return;
};
color = normalize_color(&color);
if color.is_empty() {
send_error(client_id, Arc::clone(&state), "invalid_color").await;
return;
}
let (room, user_name, chat_user_id) = {
let mut guard = state.write().await;
let Some(client) = guard.clients.get_mut(&client_id) else {
return;
};
client.color = Some(color.clone());
(client.room.clone(), client.user_name.clone(), client.chat_user_id)
};
if let Some(chat_user_id) = chat_user_id {
let _ = db::save_user_color(&config, chat_user_id, &color).await;
}
if !room.is_empty() {
state::broadcast_room(
&room,
Arc::clone(&state),
json!({"type":5, "message":"color_changed", "userName": user_name, "color": color}),
None,
)
.await;
}
}
async fn handle_rooms_command(client_id: ClientId, command: &Command, state: Arc<RwLock<ChatState>>) {
if !state::authorize(client_id, command, Arc::clone(&state)).await {
return;
}
state::send_room_list(client_id, Arc::clone(&state)).await;
}
async fn handle_userlist_command(client_id: ClientId, command: &Command, state: Arc<RwLock<ChatState>>) {
if !state::authorize(client_id, command, Arc::clone(&state)).await {
return;
}
state::send_user_list(client_id, Arc::clone(&state)).await;
}
async fn handle_start_dice_game_command(
client_id: ClientId,
command: &Command,
state: Arc<RwLock<ChatState>>,
) {
if !state::authorize(client_id, command, Arc::clone(&state)).await {
return;
}
let rounds = command
.rounds
.or_else(|| i32_from_command_value(command))
.or_else(|| command.message.as_deref().and_then(parse_roll_value))
.unwrap_or(0);
if !(1..=10).contains(&rounds) {
send_error(client_id, Arc::clone(&state), "invalid_rounds").await;
return;
}
let (room, user_name, is_admin) = {
let guard = state.read().await;
let Some(client) = guard.clients.get(&client_id) else {
return;
};
(
client.room.clone(),
client.user_name.clone(),
client.rights.contains("admin"),
)
};
if room.is_empty() {
return;
}
if !is_admin {
send_error(client_id, Arc::clone(&state), "permission_denied").await;
return;
}
let started = {
let mut guard = state.write().await;
let game = guard.dice_games.entry(room.clone()).or_default();
if game.running {
false
} else {
game.running = true;
game.current_round = 1;
game.total_rounds = rounds;
game.rolled_this_round.clear();
game.total_scores.clear();
true
}
};
if !started {
send_error(client_id, Arc::clone(&state), "dice_game_already_running").await;
return;
}
state::broadcast_room(
&room,
Arc::clone(&state),
json!({
"type":8,
"message":{"tr":"dice_game_started","rounds":rounds,"round":1},
"userName": user_name
}),
None,
)
.await;
}
async fn handle_end_dice_game_command(
client_id: ClientId,
command: &Command,
state: Arc<RwLock<ChatState>>,
) {
if !state::authorize(client_id, command, Arc::clone(&state)).await {
return;
}
let (room, is_admin) = {
let guard = state.read().await;
let Some(client) = guard.clients.get(&client_id) else {
return;
};
(client.room.clone(), client.rights.contains("admin"))
};
if room.is_empty() {
return;
}
if !is_admin {
send_error(client_id, Arc::clone(&state), "permission_denied").await;
return;
}
let ended = {
let mut guard = state.write().await;
if let Some(game) = guard.dice_games.get_mut(&room) {
if game.running {
game.running = false;
true
} else {
false
}
} else {
false
}
};
if ended {
state::broadcast_room(
&room,
Arc::clone(&state),
json!({"type":8,"message":{"tr":"dice_game_ended"}}),
None,
)
.await;
} else {
send_error(client_id, Arc::clone(&state), "dice_game_not_running").await;
}
}
async fn handle_reload_rooms_command(
client_id: ClientId,
command: &Command,
state: Arc<RwLock<ChatState>>,
config: Arc<ServerConfig>,
) {
if !state::authorize(client_id, command, Arc::clone(&state)).await {
return;
}
if let Ok(rooms) = db::load_room_configs(&config).await {
let mut guard = state.write().await;
for room in rooms {
guard.rooms.entry(room.name.clone()).or_default();
guard.room_meta.insert(room.name.clone(), room);
}
}
state::send_room_list(client_id, Arc::clone(&state)).await;
state::send_to_client(
client_id,
Arc::clone(&state),
json!({"type":"system","message":"rooms_reloaded"}),
)
.await;
}
async fn handle_unknown_command(client_id: ClientId, command: &Command, state: Arc<RwLock<ChatState>>) {
if command.password.is_some() {
send_error(
client_id,
Arc::clone(&state),
"password_protected_rooms_not_supported_yet",
)
.await;
} else {
send_error(client_id, Arc::clone(&state), "unknown_command").await;
}
}
fn room_access_allowed(
room_meta: Option<&RoomMeta>,
user_name: &str,
falukant_user_id: Option<i32>,
age: Option<i32>,
provided_password: &str,
) -> bool {
let Some(room) = room_meta else {
return false;
};
if let Some(room_password) = &room.password {
if !room_password.is_empty() && room_password != provided_password {
return false;
}
}
if let Some(min_age) = room.min_age {
if min_age >= 18 {
let Some(fid) = falukant_user_id else {
return false;
};
if fid <= 0 {
return false;
}
if let Some(user_age) = age {
if user_age < min_age {
return false;
}
}
}
}
if let Some(max_age) = room.max_age {
if let Some(user_age) = age {
if user_age > max_age {
return false;
}
}
}
if !room.is_public {
if room.owner_id.is_none() {
return false;
}
if user_name.trim().is_empty() {
return false;
}
}
true
}
fn normalize_room_name(input: &str) -> String {
let trimmed = input.trim();
if trimmed.is_empty() {
return "lobby".to_string();
}
trimmed.to_string()
}
fn normalize_color(input: &str) -> String {
let mut value = input.trim().to_string();
if value.is_empty() {
return String::new();
}
if !value.starts_with('#') {
value = format!("#{value}");
}
let raw = value.trim_start_matches('#');
match raw.len() {
3 if raw.chars().all(|c| c.is_ascii_hexdigit()) => {
let mut expanded = String::from("#");
for ch in raw.chars() {
expanded.push(ch);
expanded.push(ch);
}
expanded
}
6 if raw.chars().all(|c| c.is_ascii_hexdigit()) => format!("#{raw}"),
_ => String::new(),
}
}
fn command_from_chat_line(message: &str, token: Option<String>) -> Option<Command> {
let trimmed = message.trim();
if !trimmed.starts_with('/') {
return None;
}
let mut parts = trimmed.splitn(2, ' ');
let command = parts.next().unwrap_or_default().to_lowercase();
let rest = parts.next().unwrap_or("").trim().to_string();
match command.as_str() {
"/join" => {
if rest.is_empty() {
return None;
}
let mut args = rest.splitn(2, ' ');
let room = args.next().unwrap_or_default().trim().to_string();
let password = args.next().map(|p| p.trim().to_string());
Some(Command {
cmd_type: Value::String("join".to_string()),
token,
name: None,
room: Some(room),
message: None,
value: None,
password,
rounds: None,
to: None,
user_name: None,
})
}
"/color" => Some(Command {
cmd_type: Value::String("color".to_string()),
token,
name: None,
room: None,
message: Some(rest),
value: None,
password: None,
rounds: None,
to: None,
user_name: None,
}),
"/dice" => Some(Command {
cmd_type: Value::String("dice".to_string()),
token,
name: None,
room: None,
message: Some(if rest.is_empty() { "1d6".to_string() } else { rest }),
value: None,
password: None,
rounds: None,
to: None,
user_name: None,
}),
"/roll" => Some(Command {
cmd_type: Value::String("roll".to_string()),
token,
name: None,
room: None,
message: Some(rest),
value: None,
password: None,
rounds: None,
to: None,
user_name: None,
}),
"/start_dice_game" => Some(Command {
cmd_type: Value::String("start_dice_game".to_string()),
token,
name: None,
room: None,
message: Some(rest.clone()),
value: if rest.is_empty() { None } else { Some(Value::String(rest)) },
password: None,
rounds: None,
to: None,
user_name: None,
}),
"/end_dice_game" => Some(Command {
cmd_type: Value::String("end_dice_game".to_string()),
token,
name: None,
room: None,
message: None,
value: None,
password: None,
rounds: None,
to: None,
user_name: None,
}),
"/reload_rooms" => Some(Command {
cmd_type: Value::String("reload_rooms".to_string()),
token,
name: None,
room: None,
message: None,
value: None,
password: None,
rounds: None,
to: None,
user_name: None,
}),
"/do" => Some(Command {
cmd_type: Value::String("do".to_string()),
token,
name: None,
room: None,
message: Some(rest),
value: None,
password: None,
rounds: None,
to: None,
user_name: None,
}),
"/scream" => Some(Command {
cmd_type: Value::String("scream".to_string()),
token,
name: None,
room: None,
message: Some(rest),
value: None,
password: None,
rounds: None,
to: None,
user_name: None,
}),
"/rooms" | "/list_rooms" => Some(Command {
cmd_type: Value::String("rooms".to_string()),
token,
name: None,
room: None,
message: None,
value: None,
password: None,
rounds: None,
to: None,
user_name: None,
}),
"/userlist" => Some(Command {
cmd_type: Value::String("userlist".to_string()),
token,
name: None,
room: None,
message: None,
value: None,
password: None,
rounds: None,
to: None,
user_name: None,
}),
_ => None,
}
}
fn string_from_value(value: Option<&Value>) -> Option<String> {
let value = value?;
match value {
Value::String(s) => Some(s.clone()),
Value::Number(n) => Some(n.to_string()),
Value::Bool(b) => Some(b.to_string()),
_ => None,
}
}
fn i32_from_command_value(command: &Command) -> Option<i32> {
let value = command.value.as_ref()?;
match value {
Value::Number(n) => n.as_i64().and_then(|v| i32::try_from(v).ok()),
Value::String(s) => parse_roll_value(s),
_ => None,
}
}
fn parse_roll_value(text: &str) -> Option<i32> {
let trimmed = text.trim();
if trimmed.is_empty() {
return None;
}
if let Ok(value) = trimmed.parse::<i32>() {
return Some(value);
}
if let Some((_, rhs)) = trimmed.to_lowercase().split_once('d') {
return rhs.trim().parse::<i32>().ok();
}
None
}