Add invite mode
This commit is contained in:
parent
663a9c2d71
commit
45b6a24eda
@ -19,3 +19,5 @@ pub const MULTI_PLAYER_PLAYER_CAN_CONTINUE_AFTER_HIT: bool = true;
|
||||
pub const MULTI_PLAYER_PLAYER_BOATS: [usize; 5] = [2, 3, 3, 4, 5];
|
||||
|
||||
pub const ALPHABET: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
|
||||
pub const INVITE_CODE_LENGTH: usize = 5;
|
||||
|
100
sea_battle_backend/src/dispatcher_actor.rs
Normal file
100
sea_battle_backend/src/dispatcher_actor.rs
Normal file
@ -0,0 +1,100 @@
|
||||
//! # Dispatcher actors
|
||||
//!
|
||||
//! Allows to establish connections between human players
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use actix::{Actor, Addr, AsyncContext, Context, Handler, Message};
|
||||
|
||||
use crate::consts::INVITE_CODE_LENGTH;
|
||||
use crate::data::GameRules;
|
||||
use crate::game::Game;
|
||||
use crate::human_player_ws::{CloseConnection, HumanPlayerWS, ServerMessage, SetGame};
|
||||
use crate::utils::rand_str;
|
||||
|
||||
/// How often garbage collector is run
|
||||
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(60);
|
||||
|
||||
#[derive(Message)]
|
||||
#[rtype(result = "()")]
|
||||
pub struct CreateInvite(pub GameRules, pub Addr<HumanPlayerWS>);
|
||||
|
||||
#[derive(Message)]
|
||||
#[rtype(result = "()")]
|
||||
pub struct AcceptInvite(pub String, pub Addr<HumanPlayerWS>);
|
||||
|
||||
struct PendingPlayer {
|
||||
player: Addr<HumanPlayerWS>,
|
||||
rules: GameRules,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct DispatcherActor {
|
||||
with_invite: HashMap<String, PendingPlayer>,
|
||||
}
|
||||
|
||||
impl Actor for DispatcherActor {
|
||||
type Context = Context<Self>;
|
||||
|
||||
fn started(&mut self, ctx: &mut Self::Context) {
|
||||
ctx.run_interval(HEARTBEAT_INTERVAL, |act, _ctx| {
|
||||
// Garbage collect invites
|
||||
let ids = act
|
||||
.with_invite
|
||||
.iter()
|
||||
.filter(|p| !p.1.player.connected())
|
||||
.map(|p| p.0.clone())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for id in ids {
|
||||
log::debug!("Remove dead invite: {}", id);
|
||||
act.with_invite.remove(&id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Handler<CreateInvite> for DispatcherActor {
|
||||
type Result = ();
|
||||
|
||||
fn handle(&mut self, msg: CreateInvite, _ctx: &mut Self::Context) -> Self::Result {
|
||||
let mut invite_code = rand_str(INVITE_CODE_LENGTH).to_uppercase();
|
||||
|
||||
while self.with_invite.contains_key(&invite_code) {
|
||||
invite_code = rand_str(INVITE_CODE_LENGTH).to_uppercase();
|
||||
}
|
||||
|
||||
log::debug!("Insert new invitation: {}", invite_code);
|
||||
msg.1.do_send(ServerMessage::SetInviteCode {
|
||||
code: invite_code.clone(),
|
||||
});
|
||||
msg.1.do_send(ServerMessage::WaitingForAnotherPlayer);
|
||||
self.with_invite.insert(
|
||||
invite_code,
|
||||
PendingPlayer {
|
||||
player: msg.1,
|
||||
rules: msg.0,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl Handler<AcceptInvite> for DispatcherActor {
|
||||
type Result = ();
|
||||
|
||||
fn handle(&mut self, msg: AcceptInvite, _ctx: &mut Self::Context) -> Self::Result {
|
||||
let entry = match self.with_invite.remove(&msg.0) {
|
||||
None => {
|
||||
msg.1.do_send(ServerMessage::InvalidInviteCode);
|
||||
msg.1.do_send(CloseConnection);
|
||||
return;
|
||||
}
|
||||
Some(e) => e,
|
||||
};
|
||||
|
||||
let game = Game::new(entry.rules).start();
|
||||
entry.player.do_send(SetGame(game.clone()));
|
||||
msg.1.do_send(SetGame(game));
|
||||
}
|
||||
}
|
@ -12,6 +12,7 @@ use crate::bots::linear_bot::LinearBot;
|
||||
use crate::bots::random_bot::RandomBot;
|
||||
use crate::bots::smart_bot::SmartBot;
|
||||
use crate::data::{BoatsLayout, BotType, Coordinates, CurrentGameStatus, FireResult, GameRules};
|
||||
use crate::dispatcher_actor::{AcceptInvite, CreateInvite, DispatcherActor};
|
||||
use crate::game::{AddPlayer, Game};
|
||||
use crate::human_player::HumanPlayer;
|
||||
|
||||
@ -20,13 +21,12 @@ const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(10);
|
||||
/// How long before lack of client response causes a timeout
|
||||
const CLIENT_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
#[derive(Debug)]
|
||||
pub enum StartMode {
|
||||
Bot(GameRules),
|
||||
|
||||
#[default]
|
||||
RandomHuman,
|
||||
//TODO : create invite
|
||||
CreateInvite(GameRules),
|
||||
AcceptInvite { code: String },
|
||||
// TODO : random human
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, serde::Serialize, Debug)]
|
||||
@ -45,6 +45,10 @@ pub enum ClientMessage {
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum ServerMessage {
|
||||
SetInviteCode {
|
||||
code: String,
|
||||
},
|
||||
InvalidInviteCode,
|
||||
WaitingForAnotherPlayer,
|
||||
SetOpponentName {
|
||||
name: String,
|
||||
@ -85,23 +89,33 @@ pub enum ServerMessage {
|
||||
OpponentReplacedByBot,
|
||||
}
|
||||
|
||||
#[derive(Message)]
|
||||
#[rtype(result = "()")]
|
||||
pub struct SetGame(pub Addr<Game>);
|
||||
|
||||
#[derive(Message)]
|
||||
#[rtype(result = "()")]
|
||||
pub struct CloseConnection;
|
||||
|
||||
pub struct HumanPlayerWS {
|
||||
inner: Option<Arc<HumanPlayer>>,
|
||||
pub start_mode: StartMode,
|
||||
hb: Instant,
|
||||
}
|
||||
|
||||
impl Default for HumanPlayerWS {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
inner: None,
|
||||
start_mode: Default::default(),
|
||||
hb: Instant::now(),
|
||||
}
|
||||
}
|
||||
dispatcher: Addr<DispatcherActor>,
|
||||
name: String,
|
||||
}
|
||||
|
||||
impl HumanPlayerWS {
|
||||
pub fn new(start_mode: StartMode, dispatcher: &Addr<DispatcherActor>, name: String) -> Self {
|
||||
Self {
|
||||
inner: None,
|
||||
start_mode,
|
||||
hb: Instant::now(),
|
||||
dispatcher: dispatcher.clone(),
|
||||
name,
|
||||
}
|
||||
}
|
||||
|
||||
/// helper method that sends ping to client every second.
|
||||
///
|
||||
/// also this method checks heartbeats from client
|
||||
@ -158,7 +172,7 @@ impl Actor for HumanPlayerWS {
|
||||
};
|
||||
|
||||
let player = Arc::new(HumanPlayer {
|
||||
name: "Human".to_string(),
|
||||
name: self.name.to_string(),
|
||||
game: game.clone(),
|
||||
player: ctx.address(),
|
||||
uuid: Uuid::new_v4(),
|
||||
@ -167,8 +181,15 @@ impl Actor for HumanPlayerWS {
|
||||
game.do_send(AddPlayer(player));
|
||||
}
|
||||
|
||||
StartMode::RandomHuman => {
|
||||
unimplemented!();
|
||||
StartMode::CreateInvite(rules) => {
|
||||
log::info!("Create new play invite");
|
||||
self.dispatcher
|
||||
.do_send(CreateInvite(rules.clone(), ctx.address()));
|
||||
}
|
||||
StartMode::AcceptInvite { code } => {
|
||||
log::info!("Accept play invite {}", code);
|
||||
self.dispatcher
|
||||
.do_send(AcceptInvite(code.clone(), ctx.address()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -223,3 +244,27 @@ impl Handler<ServerMessage> for HumanPlayerWS {
|
||||
ctx.text(serde_json::to_string(&msg).unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
impl Handler<SetGame> for HumanPlayerWS {
|
||||
type Result = ();
|
||||
|
||||
fn handle(&mut self, msg: SetGame, ctx: &mut Self::Context) -> Self::Result {
|
||||
let game = msg.0;
|
||||
let player = Arc::new(HumanPlayer {
|
||||
name: self.name.clone(),
|
||||
game: game.clone(),
|
||||
player: ctx.address(),
|
||||
uuid: Uuid::new_v4(),
|
||||
});
|
||||
self.inner = Some(player.clone());
|
||||
game.do_send(AddPlayer(player));
|
||||
}
|
||||
}
|
||||
|
||||
impl Handler<CloseConnection> for HumanPlayerWS {
|
||||
type Result = ();
|
||||
|
||||
fn handle(&mut self, _msg: CloseConnection, ctx: &mut Self::Context) -> Self::Result {
|
||||
ctx.close(None)
|
||||
}
|
||||
}
|
||||
|
@ -4,9 +4,11 @@ pub mod args;
|
||||
pub mod bots;
|
||||
pub mod consts;
|
||||
pub mod data;
|
||||
pub mod dispatcher_actor;
|
||||
pub mod game;
|
||||
pub mod human_player;
|
||||
pub mod human_player_ws;
|
||||
pub mod server;
|
||||
#[cfg(test)]
|
||||
mod test;
|
||||
pub mod utils;
|
||||
|
@ -1,9 +1,11 @@
|
||||
use actix::{Actor, Addr};
|
||||
use actix_cors::Cors;
|
||||
use actix_web::{web, App, HttpRequest, HttpResponse, HttpServer, Responder};
|
||||
use actix_web_actors::ws;
|
||||
|
||||
use crate::args::Args;
|
||||
use crate::data::{GameRules, PlayConfiguration};
|
||||
use crate::dispatcher_actor::DispatcherActor;
|
||||
use crate::human_player_ws::{HumanPlayerWS, StartMode};
|
||||
|
||||
/// The default '/' route
|
||||
@ -26,23 +28,78 @@ async fn start_bot_play(
|
||||
req: HttpRequest,
|
||||
stream: web::Payload,
|
||||
query: web::Query<GameRules>,
|
||||
dispatcher: web::Data<Addr<DispatcherActor>>,
|
||||
) -> Result<HttpResponse, actix_web::Error> {
|
||||
let errors = query.0.get_errors();
|
||||
if !errors.is_empty() {
|
||||
return Ok(HttpResponse::BadRequest().json(errors));
|
||||
}
|
||||
|
||||
let mut player_ws = HumanPlayerWS::default();
|
||||
player_ws.start_mode = StartMode::Bot(query.0.clone());
|
||||
let player_ws = HumanPlayerWS::new(
|
||||
StartMode::Bot(query.0.clone()),
|
||||
&dispatcher,
|
||||
"Human".to_string(),
|
||||
);
|
||||
|
||||
let resp = ws::start(player_ws, &req, stream);
|
||||
log::info!("New bot play with configuration: {:?}", &query.0);
|
||||
resp
|
||||
}
|
||||
|
||||
/// Start game by creating invite
|
||||
async fn start_create_invite(
|
||||
req: HttpRequest,
|
||||
stream: web::Payload,
|
||||
query: web::Query<GameRules>, // TODO : add player name to query
|
||||
dispatcher: web::Data<Addr<DispatcherActor>>,
|
||||
) -> Result<HttpResponse, actix_web::Error> {
|
||||
let errors = query.0.get_errors();
|
||||
if !errors.is_empty() {
|
||||
return Ok(HttpResponse::BadRequest().json(errors));
|
||||
}
|
||||
|
||||
let player_ws = HumanPlayerWS::new(
|
||||
StartMode::CreateInvite(query.0.clone()),
|
||||
&dispatcher,
|
||||
"Human".to_string(),
|
||||
);
|
||||
|
||||
let resp = ws::start(player_ws, &req, stream);
|
||||
log::info!("New create invite play with configuration: {:?}", &query.0);
|
||||
resp
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub struct AcceptInviteQuery {
|
||||
pub code: String,
|
||||
// TODO : add player name to query
|
||||
}
|
||||
|
||||
/// Start game by creating invite
|
||||
async fn start_accept_invite(
|
||||
req: HttpRequest,
|
||||
stream: web::Payload,
|
||||
query: web::Query<AcceptInviteQuery>,
|
||||
dispatcher: web::Data<Addr<DispatcherActor>>,
|
||||
) -> Result<HttpResponse, actix_web::Error> {
|
||||
let player_ws = HumanPlayerWS::new(
|
||||
StartMode::AcceptInvite {
|
||||
code: query.code.clone(),
|
||||
},
|
||||
&dispatcher,
|
||||
"Human".to_string(),
|
||||
);
|
||||
|
||||
let resp = ws::start(player_ws, &req, stream);
|
||||
log::info!("New accept invite: {:?}", &query.0.code);
|
||||
resp
|
||||
}
|
||||
|
||||
pub async fn start_server(args: Args) -> std::io::Result<()> {
|
||||
let args_clone = args.clone();
|
||||
|
||||
let dispatcher_actor = DispatcherActor::default().start();
|
||||
|
||||
HttpServer::new(move || {
|
||||
let mut cors = Cors::default();
|
||||
match args_clone.cors.as_deref() {
|
||||
@ -52,10 +109,12 @@ pub async fn start_server(args: Args) -> std::io::Result<()> {
|
||||
}
|
||||
|
||||
App::new()
|
||||
.app_data(web::Data::new(dispatcher_actor.clone()))
|
||||
.wrap(cors)
|
||||
.route("/config", web::get().to(game_configuration))
|
||||
.route("/play/bot", web::get().to(start_bot_play))
|
||||
// TODO : create & accept invite
|
||||
.route("/play/create_invite", web::get().to(start_create_invite))
|
||||
.route("/play/accept_invite", web::get().to(start_accept_invite))
|
||||
// TODO : join random
|
||||
.route("/", web::get().to(index))
|
||||
.route("{tail:.*}", web::get().to(not_found))
|
||||
|
@ -6,6 +6,17 @@ use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
use crate::data::{BoatsLayout, GameRules};
|
||||
use crate::human_player_ws::{ClientMessage, ServerMessage};
|
||||
use crate::server::AcceptInviteQuery;
|
||||
|
||||
#[derive(Default)]
|
||||
pub enum RunMode {
|
||||
#[default]
|
||||
AgainstBot,
|
||||
CreateInvite,
|
||||
AcceptInvite {
|
||||
code: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub enum ClientEndResult {
|
||||
@ -16,10 +27,12 @@ pub enum ClientEndResult {
|
||||
InvalidBoatsLayout,
|
||||
OpponentRejectedRematch,
|
||||
OpponentLeftGame,
|
||||
InvalidInviteCode,
|
||||
}
|
||||
|
||||
pub struct BotClient {
|
||||
server: String,
|
||||
run_mode: RunMode,
|
||||
requested_rules: GameRules,
|
||||
layout: Option<BoatsLayout>,
|
||||
number_plays: usize,
|
||||
@ -33,6 +46,7 @@ impl BotClient {
|
||||
{
|
||||
Self {
|
||||
server: server.to_string(),
|
||||
run_mode: RunMode::default(),
|
||||
requested_rules: GameRules::random_players_rules(),
|
||||
layout: None,
|
||||
number_plays: 1,
|
||||
@ -40,6 +54,11 @@ impl BotClient {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_run_mode(mut self, mode: RunMode) -> Self {
|
||||
self.run_mode = mode;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_rules(mut self, rules: GameRules) -> Self {
|
||||
self.requested_rules = rules;
|
||||
self
|
||||
@ -68,11 +87,32 @@ impl BotClient {
|
||||
let mut number_victories = 0;
|
||||
let mut number_defeats = 0;
|
||||
|
||||
let url = format!(
|
||||
"{}/play/bot?{}",
|
||||
self.server.replace("http", "ws"),
|
||||
serde_urlencoded::to_string(&self.requested_rules).unwrap()
|
||||
);
|
||||
let url = match &self.run_mode {
|
||||
RunMode::AgainstBot => {
|
||||
format!(
|
||||
"{}/play/bot?{}",
|
||||
self.server.replace("http", "ws"),
|
||||
serde_urlencoded::to_string(&self.requested_rules).unwrap()
|
||||
)
|
||||
}
|
||||
RunMode::CreateInvite => {
|
||||
format!(
|
||||
"{}/play/create_invite?{}",
|
||||
self.server.replace("http", "ws"),
|
||||
serde_urlencoded::to_string(&self.requested_rules).unwrap()
|
||||
)
|
||||
}
|
||||
RunMode::AcceptInvite { code } => {
|
||||
format!(
|
||||
"{}/play/accept_invite?{}",
|
||||
self.server.replace("http", "ws"),
|
||||
serde_urlencoded::to_string(&AcceptInviteQuery {
|
||||
code: code.to_string()
|
||||
})
|
||||
.unwrap()
|
||||
)
|
||||
}
|
||||
};
|
||||
log::debug!("Connecting to {}...", url);
|
||||
let (mut socket, _) = match tokio_tungstenite::connect_async(url).await {
|
||||
Ok(s) => s,
|
||||
@ -120,6 +160,13 @@ impl BotClient {
|
||||
ServerMessage::WaitingForAnotherPlayer => {
|
||||
log::debug!("Waiting for other player...")
|
||||
}
|
||||
ServerMessage::SetInviteCode { code } => {
|
||||
log::debug!("Got invite code: {}", code);
|
||||
}
|
||||
ServerMessage::InvalidInviteCode => {
|
||||
log::debug!("Got invalid invite code!");
|
||||
return Ok(ClientEndResult::InvalidInviteCode);
|
||||
}
|
||||
ServerMessage::QueryBoatsLayout { rules } => {
|
||||
assert_eq!(&rules, &self.requested_rules);
|
||||
log::debug!("Server requested boats layout");
|
||||
|
84
sea_battle_backend/src/test/invite_mode.rs
Normal file
84
sea_battle_backend/src/test/invite_mode.rs
Normal file
@ -0,0 +1,84 @@
|
||||
use std::error::Error;
|
||||
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::mpsc::Sender;
|
||||
use tokio::task;
|
||||
|
||||
use crate::args::Args;
|
||||
use crate::human_player_ws::ServerMessage;
|
||||
use crate::server::start_server;
|
||||
use crate::test::bot_client::{ClientEndResult, RunMode};
|
||||
use crate::test::network_utils::wait_for_port;
|
||||
use crate::test::{bot_client, TestPort};
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_accept_code() {
|
||||
let _ = env_logger::builder().is_test(true).try_init();
|
||||
|
||||
let local_set = task::LocalSet::new();
|
||||
local_set
|
||||
.run_until(async move {
|
||||
task::spawn_local(start_server(Args::for_test(
|
||||
TestPort::InviteModeInvalidCode,
|
||||
)));
|
||||
wait_for_port(TestPort::InviteModeInvalidCode.port()).await;
|
||||
|
||||
let res = bot_client::BotClient::new(TestPort::InviteModeInvalidCode.as_url())
|
||||
.with_run_mode(RunMode::AcceptInvite {
|
||||
code: "BadCode".to_string(),
|
||||
})
|
||||
.run_client()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(res, ClientEndResult::InvalidInviteCode)
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn run_other_invite_side(
|
||||
sender: Sender<Result<ClientEndResult, Box<dyn Error>>>,
|
||||
port: TestPort,
|
||||
code: String,
|
||||
) {
|
||||
let res = bot_client::BotClient::new(port.as_url())
|
||||
.with_run_mode(RunMode::AcceptInvite { code })
|
||||
.run_client()
|
||||
.await;
|
||||
|
||||
sender.send(res).await.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_game() {
|
||||
let _ = env_logger::builder().is_test(true).try_init();
|
||||
|
||||
let local_set = task::LocalSet::new();
|
||||
local_set
|
||||
.run_until(async move {
|
||||
task::spawn_local(start_server(Args::for_test(TestPort::InviteModeFullGame)));
|
||||
wait_for_port(TestPort::InviteModeFullGame.port()).await;
|
||||
|
||||
let (sender, mut receiver) = mpsc::channel(1);
|
||||
|
||||
let res = bot_client::BotClient::new(TestPort::InviteModeFullGame.as_url())
|
||||
.with_run_mode(RunMode::CreateInvite)
|
||||
.with_server_msg_callback(move |msg| {
|
||||
if let ServerMessage::SetInviteCode { code } = msg {
|
||||
task::spawn_local(run_other_invite_side(
|
||||
sender.clone(),
|
||||
TestPort::InviteModeFullGame,
|
||||
code.clone(),
|
||||
));
|
||||
}
|
||||
})
|
||||
.run_client()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(res, ClientEndResult::Finished { .. }));
|
||||
let other_side_res = receiver.recv().await.unwrap().unwrap();
|
||||
assert!(matches!(other_side_res, ClientEndResult::Finished { .. }));
|
||||
})
|
||||
.await;
|
||||
}
|
@ -13,6 +13,8 @@ enum TestPort {
|
||||
LinearBotFullGame,
|
||||
LinearBotNoReplayOnHit,
|
||||
IntermediateBotFullGame,
|
||||
InviteModeInvalidCode,
|
||||
InviteModeFullGame,
|
||||
}
|
||||
|
||||
impl TestPort {
|
||||
@ -40,5 +42,6 @@ mod bot_client_bot_intermediate_play;
|
||||
mod bot_client_bot_linear_play;
|
||||
mod bot_client_bot_random_play;
|
||||
mod bot_client_bot_smart_play;
|
||||
mod invite_mode;
|
||||
mod network_utils;
|
||||
mod play_utils;
|
||||
|
19
sea_battle_backend/src/utils.rs
Normal file
19
sea_battle_backend/src/utils.rs
Normal file
@ -0,0 +1,19 @@
|
||||
use rand::distributions::Alphanumeric;
|
||||
use rand::{thread_rng, Rng};
|
||||
|
||||
/// Generate a random string of a given size
|
||||
///
|
||||
/// ```
|
||||
/// use sea_battle_backend::utils::rand_str;
|
||||
///
|
||||
/// let size = 10;
|
||||
/// let rand = rand_str(size);
|
||||
/// assert_eq!(size, rand.len());
|
||||
/// ```
|
||||
pub fn rand_str(len: usize) -> String {
|
||||
thread_rng()
|
||||
.sample_iter(&Alphanumeric)
|
||||
.map(char::from)
|
||||
.take(len)
|
||||
.collect()
|
||||
}
|
Loading…
Reference in New Issue
Block a user