feat(simulation): quarter street network — typed corridors/access/lattice (#957)
Complete the Layer-4 quarter geometry with the street layer (D-234), replacing
the ChunkLayout/CorridorSpine/AccessPoint String stubs with real types:
- AccessPoint{position,kind} + AccessKind (QuarterEdge/ReservationGate/
BlockJunction): the quarter's road nodes (from road_entry_directions octants +
reservation gates), the node set the D-097 audit reads.
- CorridorSpine{from,to,path}: arterial trunk edges, ±45°-snapped polylines
(D-096 cap). Topology gated by morphology (D-234a): Ribbon (fjord/canyon/
mountain-pass), HubSpoke (delta/island/enclosed water), Mesh/Prim-MST
(plains/meander/coastal). A spanning tree over the access nodes.
- ChunkLayout{spacing,offset,rotation_steps}: per-block local ±45° lattice,
spacing from density, offset/rotation modulated by the D-096 Grid/Organic mode.
Wired into generate_quarter_skeleton (access_points + corridors + per-block
chunk_layout). All integer-deterministic (D-010). 7 new tests.
Remaining D-234 piece: the per-edge waterfront pier/quay rule needs Layer-1
terrain water-adjacency threaded to the skeleton (cross-layer plumbing).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -26,11 +26,12 @@ use crate::seed::{SeedChain, SeedDomain};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::simulation::generator::{
|
||||
ArchitectureFlavorRef, BlockPlacement, BlockSkeleton, BuildingEntryClass, BuildingPropertyTag,
|
||||
BulkClass, CityGenerationContext, ComplexityTier, ConstructionEra, DistrictLayoutMode,
|
||||
DistrictType, EraCause, FloorExtent, FloorHeightProfile, MorphologyZone, MultiBlockReservation,
|
||||
PoliticalArchetype, QuarterId, QuarterSkeleton, ReservationFunction, ReservationId,
|
||||
SettingType, TileRect, WorldTier, ZoneTypeId, ZoningType,
|
||||
AccessKind, AccessPoint, ArchitectureFlavorRef, BlockPlacement, BlockSkeleton,
|
||||
BuildingEntryClass, BuildingPropertyTag, BulkClass, ChunkLayout, CityGenerationContext,
|
||||
ComplexityTier, ConstructionEra, CorridorSpine, DistrictLayoutMode, DistrictType, EraCause,
|
||||
FloorExtent, FloorHeightProfile, MorphologyZone, MultiBlockReservation, PoliticalArchetype,
|
||||
QuarterId, QuarterSkeleton, ReservationFunction, ReservationId, SettingType, TileRect,
|
||||
WorldTier, ZoneTypeId, ZoningType,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -102,7 +103,14 @@ pub fn generate_quarter_skeleton(
|
||||
.first()
|
||||
.cloned()
|
||||
.unwrap_or(DistrictType::MixedUse);
|
||||
let blocks = build_block_grid(&mix.districts, &block_reservation, &primary_district_type);
|
||||
let mut blocks = build_block_grid(&mix.districts, &block_reservation, &primary_district_type);
|
||||
|
||||
// ── Street network (D-234) ────────────────────────────────────────────
|
||||
// Local lattice modulation per block (Grid vs Organic), then the quarter's
|
||||
// access nodes + morphology-gated arterial corridors.
|
||||
apply_layout_to_chunks(&mut blocks, &layout_mode);
|
||||
let access_points = derive_access_points(&context.road_entry_directions, &reservations);
|
||||
let corridors = derive_corridors(&access_points, &context.morphology_zone);
|
||||
|
||||
// ── Compute z_levels ──────────────────────────────────────────────────
|
||||
// Phase 1: single-storey above ground for all non-reserved blocks.
|
||||
@@ -120,10 +128,10 @@ pub fn generate_quarter_skeleton(
|
||||
layout_mode,
|
||||
blocks,
|
||||
reservations,
|
||||
corridors: Vec::new(),
|
||||
corridors,
|
||||
z_levels,
|
||||
social_sites: Vec::new(),
|
||||
access_points: Vec::new(),
|
||||
access_points,
|
||||
society_profile: String::new(),
|
||||
zone_palette: Vec::new(),
|
||||
boundaries: String::new(),
|
||||
@@ -329,7 +337,13 @@ fn build_block_grid(
|
||||
position: (row as u8, col as u8),
|
||||
zoning,
|
||||
reservation,
|
||||
chunk_layout: String::new(), // stub
|
||||
// Density-based spacing; layout_mode offset/rotation applied in
|
||||
// the street-network step (D-234).
|
||||
chunk_layout: ChunkLayout {
|
||||
spacing: local_street_spacing(density),
|
||||
offset: (0, 0),
|
||||
rotation_steps: 0,
|
||||
},
|
||||
hosted_sites: Vec::new(),
|
||||
era_modifications: Vec::new(),
|
||||
era_cause: None,
|
||||
@@ -880,6 +894,230 @@ pub fn assign_all_block_tags(
|
||||
map
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Street network — access points, arterial corridors, local lattice (D-234, #957)
|
||||
//
|
||||
// Arterials (`corridors`) = a least-cost graph over the quarter's access nodes,
|
||||
// ±45°-snapped (D-234 refined; the node/edge graph the D-097 audit reads). Local
|
||||
// streets (`chunk_layout`) = a ±45° grid lattice per block, modulated by the
|
||||
// D-096 layout mode. Morphology gates the trunk topology (D-234a). The per-edge
|
||||
// waterfront pier rule (D-234b) needs Layer-1 terrain water-adjacency threaded to
|
||||
// the skeleton and is the remaining piece.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Quarter edge length in tiles (4 blocks × 128).
|
||||
const QUARTER_TILES: u16 = 512;
|
||||
|
||||
/// Local-street lattice spacing (tiles) from build density — denser → tighter.
|
||||
fn local_street_spacing(density_pct: u8) -> u8 {
|
||||
match density_pct {
|
||||
0..=30 => 32,
|
||||
31..=60 => 24,
|
||||
61..=85 => 16,
|
||||
_ => 12,
|
||||
}
|
||||
}
|
||||
|
||||
/// Perimeter point for a compass octant (0=N…7=NW) on the 512-tile quarter.
|
||||
fn octant_perimeter(octant: u8) -> (u16, u16) {
|
||||
let max = QUARTER_TILES - 1;
|
||||
let mid = QUARTER_TILES / 2;
|
||||
match octant % 8 {
|
||||
0 => (mid, 0),
|
||||
1 => (max, 0),
|
||||
2 => (max, mid),
|
||||
3 => (max, max),
|
||||
4 => (mid, max),
|
||||
5 => (0, max),
|
||||
6 => (0, mid),
|
||||
_ => (0, 0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Quarter access nodes (D-234): one per road-entry octant + one gate per
|
||||
/// reservation. Always non-empty (a central junction if nothing else), so the
|
||||
/// arterial graph is well-defined.
|
||||
fn derive_access_points(
|
||||
road_entry_directions: &[u8],
|
||||
reservations: &[MultiBlockReservation],
|
||||
) -> Vec<AccessPoint> {
|
||||
let mut pts = Vec::new();
|
||||
for &octant in road_entry_directions {
|
||||
pts.push(AccessPoint {
|
||||
position: octant_perimeter(octant),
|
||||
kind: AccessKind::QuarterEdge { octant },
|
||||
});
|
||||
}
|
||||
for (i, res) in reservations.iter().enumerate() {
|
||||
if let Some(&(r, c)) = res.blocks.first() {
|
||||
pts.push(AccessPoint {
|
||||
position: (c as u16 * 128 + 64, r as u16 * 128 + 64),
|
||||
kind: AccessKind::ReservationGate {
|
||||
reservation: i as ReservationId + 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
if pts.is_empty() {
|
||||
pts.push(AccessPoint {
|
||||
position: (QUARTER_TILES / 2, QUARTER_TILES / 2),
|
||||
kind: AccessKind::BlockJunction,
|
||||
});
|
||||
}
|
||||
pts
|
||||
}
|
||||
|
||||
/// Trunk-road topology permitted by the region morphology (D-234a).
|
||||
enum Topology {
|
||||
/// Linear spine along the terrain axis (fjord/canyon/mountain-pass).
|
||||
Ribbon,
|
||||
/// Star from a central hub (delta/island/enclosed water).
|
||||
HubSpoke,
|
||||
/// Least-cost mesh — any pattern (plains/meander/coastal-lowland).
|
||||
Mesh,
|
||||
}
|
||||
|
||||
fn street_topology(m: &MorphologyZone) -> Topology {
|
||||
match m {
|
||||
MorphologyZone::Fjord | MorphologyZone::Canyon | MorphologyZone::MountainPass => {
|
||||
Topology::Ribbon
|
||||
}
|
||||
MorphologyZone::Delta
|
||||
| MorphologyZone::OpenOcean
|
||||
| MorphologyZone::Lake
|
||||
| MorphologyZone::Sea
|
||||
| MorphologyZone::Island => Topology::HubSpoke,
|
||||
MorphologyZone::AlluvialPlain
|
||||
| MorphologyZone::MeanderReach
|
||||
| MorphologyZone::CoastalLowland
|
||||
| MorphologyZone::Unknown => Topology::Mesh,
|
||||
}
|
||||
}
|
||||
|
||||
/// Chebyshev distance (diagonal-friendly, matches the ±45° path metric).
|
||||
fn node_dist(a: (u16, u16), b: (u16, u16)) -> i32 {
|
||||
let dx = (a.0 as i32 - b.0 as i32).abs();
|
||||
let dy = (a.1 as i32 - b.1 as i32).abs();
|
||||
dx.max(dy)
|
||||
}
|
||||
|
||||
/// A ±45°-snapped polyline from `a` to `b`: a 45° diagonal run, then an
|
||||
/// axis-aligned run (D-096 ±45° cap — only 0°/45°/90° segments).
|
||||
fn snap45_path(a: (u16, u16), b: (u16, u16)) -> Vec<(u16, u16)> {
|
||||
let (ax, ay) = (a.0 as i32, a.1 as i32);
|
||||
let (bx, by) = (b.0 as i32, b.1 as i32);
|
||||
let diag = (bx - ax).abs().min((by - ay).abs());
|
||||
let corner = (
|
||||
(ax + (bx - ax).signum() * diag) as u16,
|
||||
(ay + (by - ay).signum() * diag) as u16,
|
||||
);
|
||||
if corner == a || corner == b {
|
||||
vec![a, b]
|
||||
} else {
|
||||
vec![a, corner, b]
|
||||
}
|
||||
}
|
||||
|
||||
fn spine(from: usize, to: usize, aps: &[AccessPoint]) -> CorridorSpine {
|
||||
CorridorSpine {
|
||||
from: from as u16,
|
||||
to: to as u16,
|
||||
path: snap45_path(aps[from].position, aps[to].position),
|
||||
}
|
||||
}
|
||||
|
||||
/// Prim's minimum-spanning tree over node positions (O(n²); n is small).
|
||||
fn prim_mst(aps: &[AccessPoint]) -> Vec<(usize, usize)> {
|
||||
let n = aps.len();
|
||||
let mut in_tree = vec![false; n];
|
||||
let mut edges = Vec::new();
|
||||
in_tree[0] = true;
|
||||
for _ in 1..n {
|
||||
let mut best: Option<(usize, usize, i32)> = None;
|
||||
for i in 0..n {
|
||||
if !in_tree[i] {
|
||||
continue;
|
||||
}
|
||||
for j in 0..n {
|
||||
if in_tree[j] {
|
||||
continue;
|
||||
}
|
||||
let d = node_dist(aps[i].position, aps[j].position);
|
||||
if best.is_none_or(|(_, _, bd)| d < bd) {
|
||||
best = Some((i, j, d));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((i, j, _)) = best {
|
||||
in_tree[j] = true;
|
||||
edges.push((i, j));
|
||||
}
|
||||
}
|
||||
edges
|
||||
}
|
||||
|
||||
/// Node closest to the quarter centre — the hub for hub-and-spoke topology.
|
||||
fn central_node(aps: &[AccessPoint]) -> usize {
|
||||
let c = (QUARTER_TILES / 2, QUARTER_TILES / 2);
|
||||
(0..aps.len())
|
||||
.min_by_key(|&i| node_dist(aps[i].position, c))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Derive the quarter's arterial corridors over its access points, with topology
|
||||
/// gated by morphology (D-234a).
|
||||
fn derive_corridors(
|
||||
access_points: &[AccessPoint],
|
||||
morphology: &MorphologyZone,
|
||||
) -> Vec<CorridorSpine> {
|
||||
let n = access_points.len();
|
||||
if n < 2 {
|
||||
return Vec::new();
|
||||
}
|
||||
match street_topology(morphology) {
|
||||
Topology::HubSpoke => {
|
||||
let hub = central_node(access_points);
|
||||
(0..n)
|
||||
.filter(|&i| i != hub)
|
||||
.map(|i| spine(hub, i, access_points))
|
||||
.collect()
|
||||
}
|
||||
Topology::Ribbon => {
|
||||
// Connect consecutive nodes ordered along the dominant terrain axis.
|
||||
let mut order: Vec<usize> = (0..n).collect();
|
||||
order.sort_by_key(|&i| {
|
||||
access_points[i].position.0 as i32 + access_points[i].position.1 as i32
|
||||
});
|
||||
order
|
||||
.windows(2)
|
||||
.map(|w| spine(w[0], w[1], access_points))
|
||||
.collect()
|
||||
}
|
||||
Topology::Mesh => prim_mst(access_points)
|
||||
.into_iter()
|
||||
.map(|(i, j)| spine(i, j, access_points))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the D-096 layout mode to each block's local lattice (D-234): organic
|
||||
/// settlements jitter the lattice offset + rotation from their `BlockPlacement`;
|
||||
/// grid settlements keep the axis-aligned default.
|
||||
fn apply_layout_to_chunks(blocks: &mut [[BlockSkeleton; 4]; 4], layout: &DistrictLayoutMode) {
|
||||
if let DistrictLayoutMode::Organic { placements } = layout {
|
||||
for r in 0..4 {
|
||||
for c in 0..4 {
|
||||
let p = &placements[r][c];
|
||||
blocks[r][c].chunk_layout.offset = (
|
||||
p.offset.0.unsigned_abs().min(255) as u8,
|
||||
p.offset.1.unsigned_abs().min(255) as u8,
|
||||
);
|
||||
blocks[r][c].chunk_layout.rotation_steps = p.rotation_steps;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1363,4 +1601,108 @@ mod tests {
|
||||
let b = assign_all_block_tags(&sk, &ctx, "manufacturing", 250, SeedChain::root(5));
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
// ── Street network (#957, D-234) ─────────────────────────────────────────
|
||||
|
||||
use crate::simulation::generator::AccessKind;
|
||||
|
||||
#[test]
|
||||
fn access_points_from_entries_and_reservations() {
|
||||
let res = vec![MultiBlockReservation {
|
||||
blocks: vec![(1, 2), (1, 3)],
|
||||
template_tag: String::new(),
|
||||
function: ReservationFunction::Park,
|
||||
z_levels: 1,
|
||||
base_z: 0,
|
||||
floor_zones: Vec::new(),
|
||||
z_band_count: 0,
|
||||
z_band_zones: Vec::new(),
|
||||
vertical_corridors: Vec::new(),
|
||||
hosted_sites: Vec::new(),
|
||||
}];
|
||||
let aps = derive_access_points(&[0, 4], &res);
|
||||
assert_eq!(aps.len(), 3); // 2 edges + 1 reservation gate
|
||||
assert!(matches!(aps[0].kind, AccessKind::QuarterEdge { octant: 0 }));
|
||||
assert!(aps
|
||||
.iter()
|
||||
.any(|a| matches!(a.kind, AccessKind::ReservationGate { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn access_points_never_empty() {
|
||||
let aps = derive_access_points(&[], &[]);
|
||||
assert_eq!(aps.len(), 1);
|
||||
assert!(matches!(aps[0].kind, AccessKind::BlockJunction));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snap45_segments_are_axis_or_diagonal() {
|
||||
let path = snap45_path((10, 20), (200, 60));
|
||||
assert!(path.len() >= 2);
|
||||
for w in path.windows(2) {
|
||||
let dx = (w[0].0 as i32 - w[1].0 as i32).abs();
|
||||
let dy = (w[0].1 as i32 - w[1].1 as i32).abs();
|
||||
// axis-aligned (one delta 0) or perfect 45° diagonal (dx == dy).
|
||||
assert!(
|
||||
dx == 0 || dy == 0 || dx == dy,
|
||||
"segment {:?}->{:?} not ±45°",
|
||||
w[0],
|
||||
w[1]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corridors_form_a_tree() {
|
||||
// A spanning tree over n nodes has n-1 edges, for each topology.
|
||||
let mesh = derive_access_points(&[0, 2, 4, 6], &[]);
|
||||
assert_eq!(
|
||||
derive_corridors(&mesh, &MorphologyZone::AlluvialPlain).len(),
|
||||
3
|
||||
);
|
||||
assert_eq!(
|
||||
derive_corridors(&mesh, &MorphologyZone::MountainPass).len(),
|
||||
3
|
||||
); // ribbon
|
||||
assert_eq!(derive_corridors(&mesh, &MorphologyZone::Delta).len(), 3); // hub-spoke
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hub_spoke_shares_a_common_node() {
|
||||
let aps = derive_access_points(&[0, 2, 4, 6], &[]);
|
||||
let corridors = derive_corridors(&aps, &MorphologyZone::Island);
|
||||
// Every spoke touches the hub.
|
||||
let hub = central_node(&aps) as u16;
|
||||
assert!(corridors.iter().all(|c| c.from == hub || c.to == hub));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skeleton_has_streets_and_local_lattice() {
|
||||
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional);
|
||||
let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42));
|
||||
assert!(!sk.access_points.is_empty());
|
||||
assert!(!sk.corridors.is_empty());
|
||||
// Local lattice spacing is set on every block.
|
||||
for row in &sk.blocks {
|
||||
for b in row {
|
||||
assert!(b.chunk_layout.spacing > 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn organic_layout_jitters_local_lattice() {
|
||||
// An old Pioneer settlement is Organic → some block lattice is rotated/offset.
|
||||
let ctx = make_context(PoliticalArchetype::Pioneer, WorldTier::Regional);
|
||||
let sk =
|
||||
generate_quarter_skeleton(&ctx, 500_000, "residential", 1, 600, SeedChain::root(7));
|
||||
if matches!(sk.layout_mode, DistrictLayoutMode::Organic { .. }) {
|
||||
let jittered = sk
|
||||
.blocks
|
||||
.iter()
|
||||
.flatten()
|
||||
.any(|b| b.chunk_layout.rotation_steps > 0 || b.chunk_layout.offset != (0, 0));
|
||||
assert!(jittered, "organic quarter should jitter some local lattice");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,10 +50,8 @@ pub type Era = String;
|
||||
pub type EraModification = String;
|
||||
/// Landmark slot description within a block. Stub.
|
||||
pub type LandmarkSlot = String;
|
||||
/// Chunk layout specification within a block (how the 2×2 chunks are arranged). Stub.
|
||||
pub type ChunkLayout = String;
|
||||
/// Corridor spine connecting district access points. Stub.
|
||||
pub type CorridorSpine = String;
|
||||
// `ChunkLayout`, `CorridorSpine`, `AccessPoint` are typed street-network structs
|
||||
// (D-234, #957) — see the "Street-network types" section below.
|
||||
/// District context — neighboring districts, world position, system. Stub.
|
||||
pub type QuarterContext = String;
|
||||
/// Society profile reference (Miri's cultural ingredients). Stub.
|
||||
@@ -64,8 +62,56 @@ pub type ZoneDefinition = String;
|
||||
pub type QuarterBoundaries = String;
|
||||
/// Guarantee audit result — tier-appropriate spatial invariant checks. Stub.
|
||||
pub type GuaranteeAuditResult = String;
|
||||
/// District access point (entry/exit to neighboring district). Stub.
|
||||
pub type AccessPoint = String;
|
||||
// ---------------------------------------------------------------------------
|
||||
// Street-network types (D-234, #957)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Where a quarter's street network connects to the outside, to a reservation,
|
||||
/// or to an interior junction (D-234). These are the nodes the arterial graph
|
||||
/// spans and the guarantee audit (D-097) reads.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AccessPoint {
|
||||
/// Tile-space position within the 512-tile quarter (0–511).
|
||||
pub position: (u16, u16),
|
||||
pub kind: AccessKind,
|
||||
}
|
||||
|
||||
/// Kind of [`AccessPoint`] (D-234).
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum AccessKind {
|
||||
/// Road enters the quarter from outside at a compass octant (0=N…7=NW),
|
||||
/// from `CityGenerationContext.road_entry_directions`.
|
||||
QuarterEdge { octant: u8 },
|
||||
/// Gate into a multi-block reservation.
|
||||
ReservationGate { reservation: ReservationId },
|
||||
/// Internal junction between blocks.
|
||||
BlockJunction,
|
||||
}
|
||||
|
||||
/// One arterial spine — a ±45°-snapped edge of the quarter's trunk-road graph
|
||||
/// (D-234 refined: arterials = least-cost graph over the access nodes). `from`/`to`
|
||||
/// index into the quarter's `access_points`; `path` is the tile-space polyline
|
||||
/// (segments axis- or 45°-aligned, D-096 ±45° cap). This node/edge graph is what
|
||||
/// the guarantee audit (D-097) reads as the trunk topology.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
||||
pub struct CorridorSpine {
|
||||
pub from: u16,
|
||||
pub to: u16,
|
||||
pub path: Vec<(u16, u16)>,
|
||||
}
|
||||
|
||||
/// Local street lattice descriptor for one block (D-234 refined: local streets =
|
||||
/// a ±45° grid lattice within each block, modulated by the D-096 layout mode).
|
||||
/// A compact parametric form the fill layer rasterizes — not the raster itself.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default)]
|
||||
pub struct ChunkLayout {
|
||||
/// Street spacing in tiles (the block is divided into spacing-sized cells).
|
||||
pub spacing: u8,
|
||||
/// Lattice origin offset in tiles (organic jitter; `(0,0)` for grid).
|
||||
pub offset: (u8, u8),
|
||||
/// Rotation in 15° steps (0–3 ⇒ 0–45°, the D-096 cap; 0 for grid).
|
||||
pub rotation_steps: u8,
|
||||
}
|
||||
/// Base visual palette for a zone. Stub.
|
||||
pub type BasePalette = String;
|
||||
/// Economic modifier on a zone palette. Stub.
|
||||
|
||||
Reference in New Issue
Block a user