feat(citygen): per-theme street subdivision and blocks
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -130,7 +130,7 @@ impl StageRunner for ArterialsStage {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::generate::testutil::small;
|
||||
use crate::geom::Rect;
|
||||
use crate::geom::{Grid, Rect};
|
||||
use crate::params::WaterMode;
|
||||
use crate::pathing::components;
|
||||
use crate::tile::Terrain;
|
||||
@@ -152,7 +152,16 @@ mod tests {
|
||||
.any(|p| st.tiles.get(p).is_some_and(|t| t.terrain == Terrain::Road));
|
||||
assert!(near, "district {:?} centre not on road", d.id);
|
||||
}
|
||||
let comps = components(st.tiles.size(), |p| st.tiles[p].terrain == Terrain::Road);
|
||||
// The arterial stamps themselves form one connected network.
|
||||
let mut mask = Grid::filled(st.tiles.size(), false);
|
||||
for path in &c.arterials {
|
||||
for p in path {
|
||||
mask.fill_rect(Rect::new(p.x - 1, p.y - 1, 4, 4), true);
|
||||
}
|
||||
}
|
||||
let comps = components(st.tiles.size(), |p| {
|
||||
mask[p] && matches!(st.tiles[p].terrain, Terrain::Road | Terrain::Bridge)
|
||||
});
|
||||
assert_eq!(comps.len(), 1);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
pub mod arterials;
|
||||
pub mod districts;
|
||||
pub mod streets;
|
||||
pub mod terrain;
|
||||
|
||||
use crate::flavor::FlavorPack;
|
||||
@@ -225,6 +226,7 @@ pub(crate) fn runner_for(stage: Stage) -> Box<dyn StageRunner> {
|
||||
Stage::Terrain => Box::new(terrain::TerrainStage::default()),
|
||||
Stage::Districts => Box::new(districts::DistrictsStage::default()),
|
||||
Stage::Arterials => Box::new(arterials::ArterialsStage::default()),
|
||||
Stage::Streets => Box::new(streets::StreetsStage::default()),
|
||||
_ => Box::new(NoopStage),
|
||||
}
|
||||
}
|
||||
|
||||
495
crates/citygen/src/generate/streets.rs
Normal file
495
crates/citygen/src/generate/streets.rs
Normal file
@@ -0,0 +1,495 @@
|
||||
//! Stage 4: per-district street subdivision and block extraction.
|
||||
|
||||
use super::{Block, GenCtx, Stage, StageRunner, StepOut};
|
||||
use crate::geom::{Pos, Rect};
|
||||
use crate::ids::DistrictId;
|
||||
use crate::params::GenError;
|
||||
use crate::rng::chance;
|
||||
use crate::theme::StreetStyle;
|
||||
use crate::tile::{Feature, Terrain};
|
||||
use crate::world::StratumKind;
|
||||
use rand::Rng;
|
||||
use rand_chacha::ChaCha8Rng;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct StreetsStage {
|
||||
next: usize,
|
||||
rng: Option<ChaCha8Rng>,
|
||||
}
|
||||
|
||||
/// Paints `terrain` at `p` only on untouched land of this district.
|
||||
fn paint_cell(ctx: &mut GenCtx, p: Pos, d: DistrictId, terrain: Terrain) {
|
||||
if ctx.is_land(p)
|
||||
&& ctx.in_district(p, d)
|
||||
&& ctx.terrain(StratumKind::Street, p) == Terrain::Void
|
||||
{
|
||||
ctx.set_terrain(StratumKind::Street, p, terrain);
|
||||
if terrain != Terrain::Sidewalk {
|
||||
ctx.scratch.road.set(p, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A straight street segment to paint.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Line {
|
||||
terrain: Terrain,
|
||||
width: i32,
|
||||
sidewalk: bool,
|
||||
}
|
||||
|
||||
impl Line {
|
||||
const fn road(width: i32, sidewalk: bool) -> Self {
|
||||
Line {
|
||||
terrain: Terrain::Road,
|
||||
width,
|
||||
sidewalk,
|
||||
}
|
||||
}
|
||||
const ALLEY: Line = Line {
|
||||
terrain: Terrain::Alley,
|
||||
width: 1,
|
||||
sidewalk: false,
|
||||
};
|
||||
}
|
||||
|
||||
/// A vertical street starting at column `x`, rows `y0..y1`.
|
||||
fn paint_vline(ctx: &mut GenCtx, x: i32, y0: i32, y1: i32, line: Line, d: DistrictId) {
|
||||
for y in y0..y1 {
|
||||
for dx in 0..line.width {
|
||||
paint_cell(ctx, Pos::new(x + dx, y), d, line.terrain);
|
||||
}
|
||||
if line.sidewalk {
|
||||
paint_cell(ctx, Pos::new(x - 1, y), d, Terrain::Sidewalk);
|
||||
paint_cell(ctx, Pos::new(x + line.width, y), d, Terrain::Sidewalk);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A horizontal street starting at row `y`, columns `x0..x1`.
|
||||
fn paint_hline(ctx: &mut GenCtx, y: i32, x0: i32, x1: i32, line: Line, d: DistrictId) {
|
||||
for x in x0..x1 {
|
||||
for dy in 0..line.width {
|
||||
paint_cell(ctx, Pos::new(x, y + dy), d, line.terrain);
|
||||
}
|
||||
if line.sidewalk {
|
||||
paint_cell(ctx, Pos::new(x, y - 1), d, Terrain::Sidewalk);
|
||||
paint_cell(ctx, Pos::new(x, y + line.width), d, Terrain::Sidewalk);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Positions of parallel street lines across `[lo, hi)` with the given pitch and a random offset.
|
||||
fn line_positions(rng: &mut ChaCha8Rng, lo: i32, hi: i32, pitch: i32) -> Vec<i32> {
|
||||
let offset = rng.random_range(0..pitch.max(1));
|
||||
let mut out = Vec::new();
|
||||
let mut x = lo + offset;
|
||||
while x < hi {
|
||||
out.push(x);
|
||||
x += pitch;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Blocks between consecutive line positions; `gap` is the street width plus sidewalks.
|
||||
fn cells_between(lines: &[i32], lo: i32, hi: i32, gap: i32, pad: i32) -> Vec<(i32, i32)> {
|
||||
let mut edges = vec![lo - gap];
|
||||
edges.extend_from_slice(lines);
|
||||
edges.push(hi);
|
||||
edges
|
||||
.windows(2)
|
||||
.map(|w| (w[0] + gap + pad, w[1] - pad))
|
||||
.filter(|(a, b)| b > a)
|
||||
.collect()
|
||||
}
|
||||
|
||||
struct Carver<'a> {
|
||||
ctx: &'a mut GenCtx,
|
||||
rng: &'a mut ChaCha8Rng,
|
||||
d: DistrictId,
|
||||
bounds: Rect,
|
||||
candidates: Vec<Rect>,
|
||||
}
|
||||
|
||||
impl Carver<'_> {
|
||||
fn grid(&mut self, bw: i32, bh: i32, sw: i32, sidewalk: bool) -> (Vec<i32>, Vec<i32>) {
|
||||
let pad = if sidewalk { 1 } else { 0 };
|
||||
let b = self.bounds;
|
||||
let xs = line_positions(self.rng, b.x, b.right(), bw + sw + 2 * pad);
|
||||
let ys = line_positions(self.rng, b.y, b.bottom(), bh + sw + 2 * pad);
|
||||
for x in &xs {
|
||||
paint_vline(
|
||||
self.ctx,
|
||||
*x,
|
||||
b.y,
|
||||
b.bottom(),
|
||||
Line::road(sw, sidewalk),
|
||||
self.d,
|
||||
);
|
||||
}
|
||||
for y in &ys {
|
||||
paint_hline(
|
||||
self.ctx,
|
||||
*y,
|
||||
b.x,
|
||||
b.right(),
|
||||
Line::road(sw, sidewalk),
|
||||
self.d,
|
||||
);
|
||||
}
|
||||
for (x0, x1) in cells_between(&xs, b.x, b.right(), sw, pad) {
|
||||
for (y0, y1) in cells_between(&ys, b.y, b.bottom(), sw, pad) {
|
||||
self.candidates.push(Rect::new(x0, y0, x1 - x0, y1 - y0));
|
||||
}
|
||||
}
|
||||
(xs, ys)
|
||||
}
|
||||
|
||||
fn organic(&mut self, rect: Rect, max_w: i32, max_h: i32, depth: u8) {
|
||||
const MIN_W: i32 = 8;
|
||||
const MIN_H: i32 = 6;
|
||||
let split_w = rect.w > max_w && rect.w > 2 * MIN_W;
|
||||
let split_h = rect.h > max_h && rect.h > 2 * MIN_H;
|
||||
if depth == 0 || (!split_w && !split_h) {
|
||||
self.candidates.push(rect);
|
||||
return;
|
||||
}
|
||||
let vertical = if split_w && split_h {
|
||||
rect.w >= rect.h
|
||||
} else {
|
||||
split_w
|
||||
};
|
||||
let jitter = self.rng.random_range(0.35..0.65);
|
||||
if vertical {
|
||||
let s = (rect.x + (rect.w as f32 * jitter) as i32)
|
||||
.clamp(rect.x + MIN_W, rect.right() - MIN_W - 1);
|
||||
paint_vline(self.ctx, s, rect.y, rect.bottom(), Line::ALLEY, self.d);
|
||||
self.organic(
|
||||
Rect::new(rect.x, rect.y, s - rect.x, rect.h),
|
||||
max_w,
|
||||
max_h,
|
||||
depth - 1,
|
||||
);
|
||||
self.organic(
|
||||
Rect::new(s + 1, rect.y, rect.right() - s - 1, rect.h),
|
||||
max_w,
|
||||
max_h,
|
||||
depth - 1,
|
||||
);
|
||||
} else {
|
||||
let s = (rect.y + (rect.h as f32 * jitter) as i32)
|
||||
.clamp(rect.y + MIN_H, rect.bottom() - MIN_H - 1);
|
||||
paint_hline(self.ctx, s, rect.x, rect.right(), Line::ALLEY, self.d);
|
||||
self.organic(
|
||||
Rect::new(rect.x, rect.y, rect.w, s - rect.y),
|
||||
max_w,
|
||||
max_h,
|
||||
depth - 1,
|
||||
);
|
||||
self.organic(
|
||||
Rect::new(rect.x, s + 1, rect.w, rect.bottom() - s - 1),
|
||||
max_w,
|
||||
max_h,
|
||||
depth - 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn rows(&mut self, row_h: i32, access_pitch: i32) {
|
||||
let b = self.bounds;
|
||||
let ys = line_positions(self.rng, b.y, b.bottom(), row_h + 4);
|
||||
let xs = line_positions(self.rng, b.x, b.right(), access_pitch);
|
||||
for y in &ys {
|
||||
paint_hline(self.ctx, *y, b.x, b.right(), Line::road(2, true), self.d);
|
||||
}
|
||||
for x in &xs {
|
||||
paint_vline(self.ctx, *x, b.y, b.bottom(), Line::road(2, true), self.d);
|
||||
}
|
||||
for (x0, x1) in cells_between(&xs, b.x, b.right(), 2, 1) {
|
||||
for (y0, y1) in cells_between(&ys, b.y, b.bottom(), 2, 1) {
|
||||
self.candidates.push(Rect::new(x0, y0, x1 - x0, y1 - y0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn shattered(&mut self) {
|
||||
let (xs, ys) = self.grid(16, 10, 2, false);
|
||||
let b = self.bounds;
|
||||
// Cut 30% of the vertical segments between consecutive horizontal streets.
|
||||
let mut ybounds = vec![b.y - 2];
|
||||
ybounds.extend_from_slice(&ys);
|
||||
ybounds.push(b.bottom());
|
||||
for x in &xs {
|
||||
for w in ybounds.windows(2) {
|
||||
if !chance(self.rng, 0.3) {
|
||||
continue;
|
||||
}
|
||||
let (y0, y1) = (w[0] + 2, w[1]);
|
||||
if y1 - y0 < 3 {
|
||||
continue;
|
||||
}
|
||||
let mut cut = Vec::new();
|
||||
for y in y0..y1 {
|
||||
for dx in 0..2 {
|
||||
let p = Pos::new(x + dx, y);
|
||||
let mine = self.ctx.in_district(p, self.d)
|
||||
&& self.ctx.terrain(StratumKind::Street, p) == Terrain::Road
|
||||
&& !self.ctx.scratch.arterial[p];
|
||||
if mine {
|
||||
self.ctx.set_terrain(StratumKind::Street, p, Terrain::Alley);
|
||||
cut.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let (Some(first), Some(last)) = (cut.first().copied(), cut.last().copied()) {
|
||||
self.ctx
|
||||
.set_feature(StratumKind::Street, first, Some(Feature::Barrier));
|
||||
self.ctx
|
||||
.set_feature(StratumKind::Street, last, Some(Feature::Barrier));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn strip(&mut self) {
|
||||
let b = self.bounds;
|
||||
let center = self.ctx.city.district(self.d).center;
|
||||
let horizontal = b.w >= b.h;
|
||||
if horizontal {
|
||||
let y = center.y - 1;
|
||||
paint_hline(self.ctx, y, b.x, b.right(), Line::road(3, true), self.d);
|
||||
let xs = line_positions(self.rng, b.x, b.right(), 10);
|
||||
for x in &xs {
|
||||
paint_vline(self.ctx, *x, b.y, b.bottom(), Line::ALLEY, self.d);
|
||||
}
|
||||
for (x0, x1) in cells_between(&xs, b.x, b.right(), 1, 0) {
|
||||
self.candidates
|
||||
.push(Rect::new(x0, b.y, x1 - x0, y - 1 - b.y));
|
||||
self.candidates
|
||||
.push(Rect::new(x0, y + 4, x1 - x0, b.bottom() - (y + 4)));
|
||||
}
|
||||
} else {
|
||||
let x = center.x - 1;
|
||||
paint_vline(self.ctx, x, b.y, b.bottom(), Line::road(3, true), self.d);
|
||||
let ys = line_positions(self.rng, b.y, b.bottom(), 10);
|
||||
for y in &ys {
|
||||
paint_hline(self.ctx, *y, b.x, b.right(), Line::ALLEY, self.d);
|
||||
}
|
||||
for (y0, y1) in cells_between(&ys, b.y, b.bottom(), 1, 0) {
|
||||
self.candidates
|
||||
.push(Rect::new(b.x, y0, x - 1 - b.x, y1 - y0));
|
||||
self.candidates
|
||||
.push(Rect::new(x + 4, y0, b.right() - (x + 4), y1 - y0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shrinks a candidate until every cell is untouched land of this district.
|
||||
fn fit_block(&self, mut r: Rect) -> Option<Rect> {
|
||||
let bad = |ctx: &GenCtx, p: Pos| {
|
||||
!ctx.is_land(p)
|
||||
|| !ctx.in_district(p, self.d)
|
||||
|| ctx.terrain(StratumKind::Street, p) != Terrain::Void
|
||||
};
|
||||
for _ in 0..12 {
|
||||
if r.w < 5 || r.h < 4 {
|
||||
return None;
|
||||
}
|
||||
let top = (r.x..r.right())
|
||||
.filter(|x| bad(self.ctx, Pos::new(*x, r.y)))
|
||||
.count();
|
||||
let bottom = (r.x..r.right())
|
||||
.filter(|x| bad(self.ctx, Pos::new(*x, r.bottom() - 1)))
|
||||
.count();
|
||||
let left = (r.y..r.bottom())
|
||||
.filter(|y| bad(self.ctx, Pos::new(r.x, *y)))
|
||||
.count();
|
||||
let right = (r.y..r.bottom())
|
||||
.filter(|y| bad(self.ctx, Pos::new(r.right() - 1, *y)))
|
||||
.count();
|
||||
if top + bottom + left + right == 0 {
|
||||
let interior_bad = r.inset(1).iter().any(|p| bad(self.ctx, p));
|
||||
if !interior_bad {
|
||||
return Some(r);
|
||||
}
|
||||
// An interior obstacle (arterial crossing); shrink the side nearest to it.
|
||||
let p = r
|
||||
.inset(1)
|
||||
.iter()
|
||||
.find(|p| bad(self.ctx, *p))
|
||||
.expect("interior_bad");
|
||||
if (p.x - r.x) * r.h < (p.y - r.y) * r.w {
|
||||
r = Rect::new(p.x + 1, r.y, r.right() - p.x - 1, r.h);
|
||||
} else {
|
||||
r = Rect::new(r.x, p.y + 1, r.w, r.bottom() - p.y - 1);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let top_f = top as f32 / r.w as f32;
|
||||
let bottom_f = bottom as f32 / r.w as f32;
|
||||
let left_f = left as f32 / r.h as f32;
|
||||
let right_f = right as f32 / r.h as f32;
|
||||
let max = top_f.max(bottom_f).max(left_f).max(right_f);
|
||||
if max == top_f {
|
||||
r = Rect::new(r.x, r.y + 1, r.w, r.h - 1);
|
||||
} else if max == bottom_f {
|
||||
r = Rect::new(r.x, r.y, r.w, r.h - 1);
|
||||
} else if max == left_f {
|
||||
r = Rect::new(r.x + 1, r.y, r.w - 1, r.h);
|
||||
} else {
|
||||
r = Rect::new(r.x, r.y, r.w - 1, r.h);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl StreetsStage {
|
||||
fn carve_district(&mut self, ctx: &mut GenCtx, idx: usize) {
|
||||
let district = ctx.city.districts[idx].clone();
|
||||
let spec = district.theme.spec();
|
||||
let rng = self.rng.as_mut().expect("begin sets rng");
|
||||
let mut carver = Carver {
|
||||
ctx,
|
||||
rng,
|
||||
d: district.id,
|
||||
bounds: district.bounds,
|
||||
candidates: Vec::new(),
|
||||
};
|
||||
match spec.street_style {
|
||||
StreetStyle::Grid => {
|
||||
carver.grid(spec.block.0, spec.block.1, 2, true);
|
||||
}
|
||||
StreetStyle::Superblock => {
|
||||
carver.grid(spec.block.0, spec.block.1, 2, true);
|
||||
}
|
||||
StreetStyle::Organic => {
|
||||
let (max_w, max_h) = (spec.block.0 * 3 / 2, spec.block.1 * 3 / 2);
|
||||
let b = carver.bounds;
|
||||
carver.organic(b, max_w, max_h, 7);
|
||||
}
|
||||
StreetStyle::Rows => carver.rows(14, 24),
|
||||
StreetStyle::Shattered => carver.shattered(),
|
||||
StreetStyle::Strip => carver.strip(),
|
||||
}
|
||||
let candidates = std::mem::take(&mut carver.candidates);
|
||||
let mut blocks: Vec<Block> = Vec::new();
|
||||
for c in candidates {
|
||||
let Some(c) = c.clamp_to(carver.bounds) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(fitted) = carver.fit_block(c)
|
||||
&& blocks.iter().all(|b| !b.rect.intersects(fitted))
|
||||
{
|
||||
blocks.push(Block {
|
||||
rect: fitted,
|
||||
district: district.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
carver.ctx.scratch.blocks.extend(blocks);
|
||||
}
|
||||
}
|
||||
|
||||
impl StageRunner for StreetsStage {
|
||||
fn begin(&mut self, ctx: &mut GenCtx) -> Result<(), GenError> {
|
||||
self.rng = Some(ctx.rng(Stage::Streets));
|
||||
self.next = 0;
|
||||
ctx.scratch.blocks.clear();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn step(&mut self, ctx: &mut GenCtx) -> Result<StepOut, GenError> {
|
||||
let n = ctx.city.districts.len();
|
||||
if self.next >= n {
|
||||
return Ok(StepOut::Finished);
|
||||
}
|
||||
self.carve_district(ctx, self.next);
|
||||
self.next += 1;
|
||||
Ok(StepOut::Continue(self.next as f32 / n as f32))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::generate::testutil::small;
|
||||
use crate::generate::{Generator, Stage};
|
||||
use crate::params::{GenParams, SizePreset, WaterMode};
|
||||
use crate::pathing::components;
|
||||
use crate::tile::{Feature, Terrain};
|
||||
use crate::world::{StratumKind, Theme};
|
||||
|
||||
#[test]
|
||||
fn blocks_are_inside_districts_and_off_roads() {
|
||||
let ctx = Generator::run_until(
|
||||
GenParams {
|
||||
seed: 31,
|
||||
water: WaterMode::Bay,
|
||||
districts: 7,
|
||||
size: SizePreset::Custom { w: 120, h: 60 },
|
||||
..Default::default()
|
||||
},
|
||||
Stage::Streets,
|
||||
);
|
||||
let st = ctx.city.stratum(StratumKind::Street).unwrap();
|
||||
let blocks = &ctx.scratch.blocks;
|
||||
assert!(blocks.len() > 20, "only {} blocks", blocks.len());
|
||||
for b in blocks {
|
||||
assert!(b.rect.w >= 5 && b.rect.h >= 4, "{:?}", b.rect);
|
||||
for p in b.rect.iter() {
|
||||
let t = &st.tiles[p];
|
||||
assert_eq!(t.district, Some(b.district), "{p:?} in {:?}", b.rect);
|
||||
assert!(
|
||||
!matches!(
|
||||
t.terrain,
|
||||
Terrain::Road | Terrain::Alley | Terrain::Water | Terrain::Sidewalk
|
||||
),
|
||||
"{p:?} is {:?}",
|
||||
t.terrain
|
||||
);
|
||||
}
|
||||
}
|
||||
for (i, a) in blocks.iter().enumerate() {
|
||||
for b in &blocks[i + 1..] {
|
||||
assert!(
|
||||
!a.rect.intersects(b.rect),
|
||||
"{:?} overlaps {:?}",
|
||||
a.rect,
|
||||
b.rect
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn street_network_is_connected_per_street_level() {
|
||||
let c = small(31, WaterMode::None, 6);
|
||||
let st = c.stratum(StratumKind::Street).unwrap();
|
||||
let comps = components(st.tiles.size(), |p| {
|
||||
matches!(
|
||||
st.tiles[p].terrain,
|
||||
Terrain::Road | Terrain::Alley | Terrain::Sidewalk
|
||||
)
|
||||
});
|
||||
let total: usize = comps.iter().map(|c| c.len()).sum();
|
||||
let share = comps[0].len() as f32 / total as f32;
|
||||
assert!(share > 0.97, "main street component only {share}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combat_zone_has_barriers_and_grid_has_none() {
|
||||
let c = small(31, WaterMode::None, 7);
|
||||
let st = c.stratum(StratumKind::Street).unwrap();
|
||||
let count = |theme: Theme| {
|
||||
st.tiles
|
||||
.iter()
|
||||
.filter(|(_, t)| {
|
||||
t.feature == Some(Feature::Barrier)
|
||||
&& t.district.map(|d| c.district(d).theme) == Some(theme)
|
||||
})
|
||||
.count()
|
||||
};
|
||||
assert!(count(Theme::CombatZone) > 0);
|
||||
assert_eq!(count(Theme::CorpCore), 0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user