mirror of
https://gitlab.com/veloren/veloren.git
synced 2024-08-30 18:12:32 +00:00
add physics tests that verify the status quo
This commit is contained in:
parent
35f9c5cbdf
commit
997b330f19
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -6511,6 +6511,7 @@ dependencies = [
|
||||
"veloren-common-base",
|
||||
"veloren-common-ecs",
|
||||
"veloren-common-net",
|
||||
"veloren-common-state",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -36,6 +36,15 @@ impl MaterialStatManifest {
|
||||
pub fn armor_stats(&self, key: &str) -> Option<armor::Stats> {
|
||||
self.armor_stats.get(key).copied()
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
/// needed for tests to load it without actual assets
|
||||
pub fn with_empty() -> Self {
|
||||
Self {
|
||||
tool_stats: hashbrown::HashMap::default(),
|
||||
armor_stats: hashbrown::HashMap::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This could be a Compound that also loads the keys, but the RecipeBook
|
||||
|
@ -31,3 +31,7 @@ specs = { git = "https://github.com/amethyst/specs.git", features = ["serde", "s
|
||||
|
||||
# Tweak running code
|
||||
# inline_tweak = { version = "1.0.8", features = ["release_tweak"] }
|
||||
|
||||
[dev-dependencies]
|
||||
# Setup a State
|
||||
common-state = { package = "veloren-common-state", path = "../state" }
|
296
common/systems/tests/phys/basic.rs
Normal file
296
common/systems/tests/phys/basic.rs
Normal file
@ -0,0 +1,296 @@
|
||||
use crate::utils;
|
||||
use approx::assert_relative_eq;
|
||||
use common::{comp::Controller, resources::Time};
|
||||
use specs::WorldExt;
|
||||
use std::error::Error;
|
||||
use utils::{DT, DT_F64, EPSILON};
|
||||
use vek::{approx, Vec2, Vec3};
|
||||
use veloren_common_systems::add_local_systems;
|
||||
|
||||
#[test]
|
||||
fn simple_run() {
|
||||
let mut state = utils::setup();
|
||||
utils::create_player(&mut state);
|
||||
state.tick(
|
||||
DT,
|
||||
|dispatcher_builder| {
|
||||
add_local_systems(dispatcher_builder);
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dont_fall_outside_world() -> Result<(), Box<dyn Error>> {
|
||||
let mut state = utils::setup();
|
||||
let p1 = utils::create_player(&mut state);
|
||||
|
||||
{
|
||||
let mut storage = state.ecs_mut().write_storage::<common::comp::Pos>();
|
||||
storage
|
||||
.insert(p1, common::comp::Pos(Vec3::new(1000.0, 1000.0, 265.0)))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let (pos, vel, _) = utils::get_transform(&state, p1)?;
|
||||
assert_relative_eq!(pos.0.x, 1000.0);
|
||||
assert_relative_eq!(pos.0.y, 1000.0);
|
||||
assert_relative_eq!(pos.0.z, 265.0);
|
||||
assert_eq!(vel.0, vek::Vec3::zero());
|
||||
|
||||
utils::tick(&mut state, DT);
|
||||
let (pos, vel, _) = utils::get_transform(&state, p1)?;
|
||||
assert_relative_eq!(pos.0.x, 1000.0);
|
||||
assert_relative_eq!(pos.0.y, 1000.0);
|
||||
assert_relative_eq!(pos.0.z, 265.0);
|
||||
assert_eq!(vel.0, vek::Vec3::zero());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fall_simple() -> Result<(), Box<dyn Error>> {
|
||||
let mut state = utils::setup();
|
||||
let p1 = utils::create_player(&mut state);
|
||||
|
||||
let (pos, vel, _) = utils::get_transform(&state, p1)?;
|
||||
assert_relative_eq!(pos.0.x, 16.0);
|
||||
assert_relative_eq!(pos.0.y, 16.0);
|
||||
assert_relative_eq!(pos.0.z, 265.0);
|
||||
assert_eq!(vel.0, vek::Vec3::zero());
|
||||
|
||||
utils::tick(&mut state, DT);
|
||||
let (pos, vel, _) = utils::get_transform(&state, p1)?;
|
||||
assert_relative_eq!(pos.0.x, 16.0);
|
||||
assert_relative_eq!(pos.0.y, 16.0);
|
||||
assert_relative_eq!(pos.0.z, 264.9975, epsilon = EPSILON);
|
||||
assert_relative_eq!(vel.0.z, -0.25, epsilon = EPSILON);
|
||||
|
||||
utils::tick(&mut state, DT);
|
||||
let (pos, vel, _) = utils::get_transform(&state, p1)?;
|
||||
assert_relative_eq!(pos.0.z, 264.9925, epsilon = EPSILON);
|
||||
assert_relative_eq!(vel.0.z, -0.49969065, epsilon = EPSILON);
|
||||
|
||||
utils::tick(&mut state, DT);
|
||||
let (pos, vel, _) = utils::get_transform(&state, p1)?;
|
||||
assert_relative_eq!(pos.0.z, 264.985, epsilon = EPSILON);
|
||||
assert_relative_eq!(vel.0.z, -0.7493813, epsilon = EPSILON);
|
||||
|
||||
utils::tick(&mut state, DT * 7);
|
||||
let (pos, vel, _) = utils::get_transform(&state, p1)?;
|
||||
assert_relative_eq!(state.ecs_mut().read_resource::<Time>().0, DT_F64 * 10.0);
|
||||
assert_relative_eq!(pos.0.z, 264.8102, epsilon = EPSILON);
|
||||
assert_relative_eq!(vel.0.z, -2.4969761, epsilon = EPSILON);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// will fall in 20 x DT and 2 x 10*DT steps. compare the end result and make
|
||||
/// log the "error" between both caluclation
|
||||
fn fall_dt_speed_diff() -> Result<(), Box<dyn Error>> {
|
||||
let mut sstate = utils::setup();
|
||||
let mut fstate = utils::setup();
|
||||
let sp1 = utils::create_player(&mut sstate);
|
||||
let fp1 = utils::create_player(&mut fstate);
|
||||
|
||||
for _ in 0..10 {
|
||||
utils::tick(&mut sstate, DT);
|
||||
}
|
||||
utils::tick(&mut fstate, DT * 10);
|
||||
|
||||
let (spos, svel, _) = utils::get_transform(&sstate, sp1)?;
|
||||
let (fpos, fvel, _) = utils::get_transform(&fstate, fp1)?;
|
||||
assert_relative_eq!(spos.0.x, 16.0);
|
||||
assert_relative_eq!(spos.0.y, 16.0);
|
||||
assert_relative_eq!(spos.0.z, 264.86267, epsilon = EPSILON);
|
||||
assert_relative_eq!(svel.0.z, -2.496151, epsilon = EPSILON);
|
||||
assert_relative_eq!(fpos.0.x, 16.0);
|
||||
assert_relative_eq!(fpos.0.y, 16.0);
|
||||
assert_relative_eq!(fpos.0.z, 264.75, epsilon = EPSILON);
|
||||
assert_relative_eq!(fvel.0.z, -2.5, epsilon = EPSILON);
|
||||
|
||||
assert_relative_eq!((spos.0.z - fpos.0.z).abs(), 0.1126709, epsilon = EPSILON);
|
||||
assert_relative_eq!((svel.0.z - fvel.0.z).abs(), 0.0038490295, epsilon = EPSILON);
|
||||
|
||||
for _ in 0..10 {
|
||||
utils::tick(&mut sstate, DT);
|
||||
}
|
||||
utils::tick(&mut fstate, DT * 10);
|
||||
|
||||
let (spos, svel, _) = utils::get_transform(&sstate, sp1)?;
|
||||
let (fpos, fvel, _) = utils::get_transform(&fstate, fp1)?;
|
||||
assert_relative_eq!(spos.0.x, 16.0);
|
||||
assert_relative_eq!(spos.0.y, 16.0);
|
||||
assert_relative_eq!(spos.0.z, 264.47607, epsilon = EPSILON);
|
||||
assert_relative_eq!(svel.0.z, -4.9847627, epsilon = EPSILON);
|
||||
assert_relative_eq!(fpos.0.x, 16.0);
|
||||
assert_relative_eq!(fpos.0.y, 16.0);
|
||||
assert_relative_eq!(fpos.0.z, 264.25073, epsilon = EPSILON);
|
||||
assert_relative_eq!(fvel.0.z, -4.9930925, epsilon = EPSILON);
|
||||
|
||||
// Diff after 200ms
|
||||
assert_relative_eq!((spos.0.z - fpos.0.z).abs(), 0.2253418, epsilon = EPSILON);
|
||||
assert_relative_eq!((svel.0.z - fvel.0.z).abs(), 0.008329868, epsilon = EPSILON);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walk_simple() -> Result<(), Box<dyn Error>> {
|
||||
let mut state = utils::setup();
|
||||
let p1 = utils::create_player(&mut state);
|
||||
|
||||
for _ in 0..100 {
|
||||
utils::tick(&mut state, DT);
|
||||
}
|
||||
let (pos, vel, _) = utils::get_transform(&state, p1)?;
|
||||
assert_relative_eq!(pos.0.z, 257.0); // make sure it landed on ground
|
||||
assert_eq!(vel.0, vek::Vec3::zero());
|
||||
|
||||
let mut actions = Controller::default();
|
||||
actions.inputs.move_dir = Vec2::new(1.0, 0.0);
|
||||
utils::set_control(&mut state, p1, actions)?;
|
||||
|
||||
utils::tick(&mut state, DT);
|
||||
let (pos, vel, _) = utils::get_transform(&state, p1)?;
|
||||
assert_relative_eq!(pos.0.x, 16.01, epsilon = EPSILON);
|
||||
assert_relative_eq!(pos.0.y, 16.0);
|
||||
assert_relative_eq!(pos.0.z, 257.0);
|
||||
assert_relative_eq!(vel.0.x, 0.90703666, epsilon = EPSILON);
|
||||
assert_relative_eq!(vel.0.y, 0.0);
|
||||
assert_relative_eq!(vel.0.z, 0.0);
|
||||
|
||||
utils::tick(&mut state, DT);
|
||||
let (pos, vel, _) = utils::get_transform(&state, p1)?;
|
||||
assert_relative_eq!(pos.0.x, 16.029068, epsilon = EPSILON);
|
||||
assert_relative_eq!(vel.0.x, 1.7296565, epsilon = EPSILON);
|
||||
|
||||
utils::tick(&mut state, DT);
|
||||
let (pos, vel, _) = utils::get_transform(&state, p1)?;
|
||||
assert_relative_eq!(pos.0.x, 16.05636, epsilon = EPSILON);
|
||||
assert_relative_eq!(vel.0.x, 2.4756372, epsilon = EPSILON);
|
||||
|
||||
for _ in 0..8 {
|
||||
utils::tick(&mut state, DT);
|
||||
}
|
||||
let (pos, vel, _) = utils::get_transform(&state, p1)?;
|
||||
assert_relative_eq!(pos.0.x, 16.492111, epsilon = EPSILON);
|
||||
assert_relative_eq!(vel.0.x, 6.411994, epsilon = EPSILON);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walk_max() -> Result<(), Box<dyn Error>> {
|
||||
let mut state = utils::setup();
|
||||
for x in 2..30 {
|
||||
utils::generate_chunk(&mut state, Vec2::new(x, 0));
|
||||
}
|
||||
let p1 = utils::create_player(&mut state);
|
||||
|
||||
for _ in 0..100 {
|
||||
utils::tick(&mut state, DT);
|
||||
}
|
||||
|
||||
let mut actions = Controller::default();
|
||||
actions.inputs.move_dir = Vec2::new(1.0, 0.0);
|
||||
utils::set_control(&mut state, p1, actions)?;
|
||||
|
||||
for _ in 0..500 {
|
||||
utils::tick(&mut state, DT);
|
||||
}
|
||||
let (pos, vel, _) = utils::get_transform(&state, p1)?;
|
||||
assert_relative_eq!(pos.0.x, 68.40794, epsilon = EPSILON);
|
||||
assert_relative_eq!(vel.0.x, 9.695188, epsilon = EPSILON);
|
||||
for _ in 0..100 {
|
||||
utils::tick(&mut state, DT);
|
||||
}
|
||||
let (_, vel, _) = utils::get_transform(&state, p1)?;
|
||||
assert_relative_eq!(vel.0.x, 9.695188, epsilon = EPSILON);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// will run in 20 x DT and 2 x 10*DT steps. compare the end result and make
|
||||
/// log the "error" between both caluclation
|
||||
fn walk_dt_speed_diff() -> Result<(), Box<dyn Error>> {
|
||||
let mut sstate = utils::setup();
|
||||
let mut fstate = utils::setup();
|
||||
let sp1 = utils::create_player(&mut sstate);
|
||||
let fp1 = utils::create_player(&mut fstate);
|
||||
|
||||
for _ in 0..100 {
|
||||
utils::tick(&mut sstate, DT);
|
||||
utils::tick(&mut fstate, DT);
|
||||
}
|
||||
|
||||
let mut actions = Controller::default();
|
||||
actions.inputs.move_dir = Vec2::new(1.0, 0.0);
|
||||
utils::set_control(&mut sstate, sp1, actions.clone())?;
|
||||
utils::set_control(&mut fstate, fp1, actions)?;
|
||||
|
||||
for _ in 0..10 {
|
||||
utils::tick(&mut sstate, DT);
|
||||
}
|
||||
utils::tick(&mut fstate, DT * 10);
|
||||
|
||||
let (spos, svel, _) = utils::get_transform(&sstate, sp1)?;
|
||||
let (fpos, fvel, _) = utils::get_transform(&fstate, fp1)?;
|
||||
assert_relative_eq!(spos.0.x, 16.421423, epsilon = EPSILON);
|
||||
assert_relative_eq!(spos.0.y, 16.0);
|
||||
assert_relative_eq!(spos.0.z, 257.0);
|
||||
assert_relative_eq!(svel.0.x, 6.071788, epsilon = EPSILON);
|
||||
assert_relative_eq!(fpos.0.x, 16.993896, epsilon = EPSILON);
|
||||
assert_relative_eq!(fpos.0.y, 16.0);
|
||||
assert_relative_eq!(fpos.0.z, 257.0);
|
||||
assert_relative_eq!(fvel.0.x, 3.7484815, epsilon = EPSILON);
|
||||
|
||||
assert_relative_eq!((spos.0.x - fpos.0.x).abs(), 0.5724735, epsilon = EPSILON);
|
||||
assert_relative_eq!((svel.0.x - fvel.0.x).abs(), 2.3233063, epsilon = EPSILON);
|
||||
|
||||
for _ in 0..10 {
|
||||
utils::tick(&mut sstate, DT);
|
||||
}
|
||||
utils::tick(&mut fstate, DT * 10);
|
||||
|
||||
let (spos, svel, _) = utils::get_transform(&sstate, sp1)?;
|
||||
let (fpos, fvel, _) = utils::get_transform(&fstate, fp1)?;
|
||||
assert_relative_eq!(spos.0.x, 17.248621, epsilon = EPSILON);
|
||||
assert_relative_eq!(svel.0.x, 8.344364, epsilon = EPSILON);
|
||||
assert_relative_eq!(fpos.0.x, 18.357212, epsilon = EPSILON);
|
||||
assert_relative_eq!(fvel.0.x, 5.1417327, epsilon = EPSILON);
|
||||
|
||||
// Diff after 200ms
|
||||
assert_relative_eq!((spos.0.x - fpos.0.x).abs(), 1.1085911, epsilon = EPSILON);
|
||||
assert_relative_eq!((svel.0.x - fvel.0.x).abs(), 3.2026315, epsilon = EPSILON);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cant_run_during_fall() -> Result<(), Box<dyn Error>> {
|
||||
let mut state = utils::setup();
|
||||
let p1 = utils::create_player(&mut state);
|
||||
|
||||
let mut actions = Controller::default();
|
||||
actions.inputs.move_dir = Vec2::new(1.0, 0.0);
|
||||
utils::set_control(&mut state, p1, actions)?;
|
||||
|
||||
utils::tick(&mut state, DT * 2);
|
||||
let (pos, vel, _) = utils::get_transform(&state, p1)?;
|
||||
assert_relative_eq!(pos.0.x, 16.0);
|
||||
assert_relative_eq!(pos.0.y, 16.0);
|
||||
assert_relative_eq!(vel.0.x, 0.0);
|
||||
assert_relative_eq!(vel.0.y, 0.0);
|
||||
|
||||
utils::tick(&mut state, DT * 2);
|
||||
let (_, vel, _) = utils::get_transform(&state, p1)?;
|
||||
assert_relative_eq!(state.ecs_mut().read_resource::<Time>().0, DT_F64 * 4.0);
|
||||
assert_relative_eq!(pos.0.x, 16.0);
|
||||
assert_relative_eq!(pos.0.y, 16.0);
|
||||
assert_relative_eq!(vel.0.x, 0.04999693, epsilon = EPSILON);
|
||||
assert_relative_eq!(vel.0.y, 0.0, epsilon = EPSILON);
|
||||
|
||||
Ok(())
|
||||
}
|
2
common/systems/tests/phys/main.rs
Normal file
2
common/systems/tests/phys/main.rs
Normal file
@ -0,0 +1,2 @@
|
||||
mod basic;
|
||||
mod utils;
|
139
common/systems/tests/phys/utils.rs
Normal file
139
common/systems/tests/phys/utils.rs
Normal file
@ -0,0 +1,139 @@
|
||||
use common::{
|
||||
comp::{
|
||||
inventory::item::MaterialStatManifest,
|
||||
skills::{GeneralSkill, Skill},
|
||||
Auras, Buffs, CharacterState, Collider, Combo, Controller, Energy, Health, Ori, Pos, Stats,
|
||||
Vel,
|
||||
},
|
||||
resources::{DeltaTime, GameMode, Time},
|
||||
skillset_builder::SkillSetBuilder,
|
||||
terrain::{Block, BlockKind, SpriteKind, TerrainChunk, TerrainChunkMeta, TerrainGrid},
|
||||
};
|
||||
use common_ecs::{dispatch, System};
|
||||
use common_net::sync::WorldSyncExt;
|
||||
use common_state::State;
|
||||
use rand::{prelude::*, rngs::SmallRng};
|
||||
use specs::{Builder, Entity, WorldExt};
|
||||
use std::{error::Error, sync::Arc, time::Duration};
|
||||
use vek::{Rgb, Vec2, Vec3};
|
||||
use veloren_common_systems::{character_behavior, phys};
|
||||
|
||||
pub const EPSILON: f32 = 0.00002;
|
||||
const DT_MILLIS: u64 = 10;
|
||||
const MILLIS_PER_SEC: f64 = 1_000.0;
|
||||
pub const DT: Duration = Duration::from_millis(DT_MILLIS);
|
||||
pub const DT_F64: f64 = DT_MILLIS as f64 / MILLIS_PER_SEC;
|
||||
|
||||
pub fn setup() -> State {
|
||||
let mut state = State::new(GameMode::Server);
|
||||
state.ecs_mut().insert(MaterialStatManifest::with_empty());
|
||||
state.ecs_mut().read_resource::<Time>();
|
||||
state.ecs_mut().read_resource::<DeltaTime>();
|
||||
state.ecs_mut().insert(TerrainGrid::new());
|
||||
for x in 0..2 {
|
||||
for y in 0..2 {
|
||||
generate_chunk(&mut state, Vec2::new(x, y));
|
||||
}
|
||||
}
|
||||
|
||||
state
|
||||
}
|
||||
|
||||
pub fn tick(state: &mut State, dt: Duration) {
|
||||
state.tick(
|
||||
dt,
|
||||
|dispatch_builder| {
|
||||
dispatch::<character_behavior::Sys>(dispatch_builder, &[]);
|
||||
dispatch::<phys::Sys>(dispatch_builder, &[&character_behavior::Sys::sys_name()]);
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn set_control(
|
||||
state: &mut State,
|
||||
entity: Entity,
|
||||
control: Controller,
|
||||
) -> Result<(), specs::error::Error> {
|
||||
let mut storage = state.ecs_mut().write_storage::<Controller>();
|
||||
storage.insert(entity, control).map(|_| ())
|
||||
}
|
||||
|
||||
pub fn get_transform(state: &State, entity: Entity) -> Result<(Pos, Vel, Ori), Box<dyn Error>> {
|
||||
let storage = state.ecs().read_storage::<Pos>();
|
||||
let pos = *storage
|
||||
.get(entity)
|
||||
.ok_or("Storage does not contain Entity Pos")?;
|
||||
let storage = state.ecs().read_storage::<Vel>();
|
||||
let vel = *storage
|
||||
.get(entity)
|
||||
.ok_or("Storage does not contain Entity Vel")?;
|
||||
let storage = state.ecs().read_storage::<Ori>();
|
||||
let ori = *storage
|
||||
.get(entity)
|
||||
.ok_or("Storage does not contain Entity Ori")?;
|
||||
|
||||
Ok((pos, vel, ori))
|
||||
}
|
||||
|
||||
pub fn create_player(state: &mut State) -> Entity {
|
||||
let body = common::comp::Body::Humanoid(common::comp::humanoid::Body::random_with(
|
||||
&mut thread_rng(),
|
||||
&common::comp::humanoid::Species::Human,
|
||||
));
|
||||
let (p0, p1, radius) = body.sausage();
|
||||
let collider = Collider::CapsulePrism {
|
||||
p0,
|
||||
p1,
|
||||
radius,
|
||||
z_min: 0.0,
|
||||
z_max: body.height(),
|
||||
};
|
||||
let skill_set = SkillSetBuilder::default().build();
|
||||
|
||||
state
|
||||
.ecs_mut()
|
||||
.create_entity_synced()
|
||||
.with(Pos(Vec3::new(16.0, 16.0, 265.0)))
|
||||
.with(Vel::default())
|
||||
.with(Ori::default())
|
||||
.with(body.mass())
|
||||
.with(body.density())
|
||||
.with(collider)
|
||||
.with(body)
|
||||
.with(Controller::default())
|
||||
.with(CharacterState::default())
|
||||
.with(Buffs::default())
|
||||
.with(Combo::default())
|
||||
.with(Auras::default())
|
||||
.with(Energy::new(
|
||||
body,
|
||||
skill_set
|
||||
.skill_level(Skill::General(GeneralSkill::EnergyIncrease))
|
||||
.unwrap_or(0),
|
||||
))
|
||||
.with(Health::new(body, body.base_health()))
|
||||
.with(skill_set)
|
||||
.with(Stats::empty())
|
||||
.build()
|
||||
}
|
||||
|
||||
pub fn generate_chunk(state: &mut State, chunk_pos: Vec2<i32>) {
|
||||
let (x, y) = chunk_pos.map(|e| e.to_le_bytes()).into_tuple();
|
||||
let mut rng = SmallRng::from_seed([
|
||||
x[0], x[1], x[2], x[3], y[0], y[1], y[2], y[3], x[0], x[1], x[2], x[3], y[0], y[1], y[2],
|
||||
y[3], x[0], x[1], x[2], x[3], y[0], y[1], y[2], y[3], x[0], x[1], x[2], x[3], y[0], y[1],
|
||||
y[2], y[3],
|
||||
]);
|
||||
let height = rng.gen::<i32>() % 8;
|
||||
|
||||
state.ecs().write_resource::<TerrainGrid>().insert(
|
||||
chunk_pos,
|
||||
Arc::new(TerrainChunk::new(
|
||||
256 + if rng.gen::<u8>() < 64 { height } else { 0 },
|
||||
Block::new(BlockKind::Grass, Rgb::new(11, 102, 35)),
|
||||
Block::air(SpriteKind::Empty),
|
||||
TerrainChunkMeta::void(),
|
||||
)),
|
||||
);
|
||||
}
|
Loading…
Reference in New Issue
Block a user