codex: stuff
This commit is contained in:
8
.codex/AGENTS.md
Normal file
8
.codex/AGENTS.md
Normal file
@@ -0,0 +1,8 @@
|
||||
This project is a Minecraft-like voxel system for Roblox using Rojo. The server
|
||||
is authoritative over chunks and blocks. Clients only handle input, UI, and
|
||||
requests. Clients must never create or destroy blocks directly. The server must
|
||||
validate distance, block type, and target. Shared modules must not reference
|
||||
Roblox services.
|
||||
|
||||
Keep in mind that to replicate anything across the client-server model or to
|
||||
write to any instance you must use serial luau
|
||||
7
src/ReplicatedStorage/Remotes/BreakBlock.rbxmx
Normal file
7
src/ReplicatedStorage/Remotes/BreakBlock.rbxmx
Normal file
@@ -0,0 +1,7 @@
|
||||
<roblox version="4">
|
||||
<Item class="RemoteEvent" referent="RBX0D1D3E8A8F5C4B0B9F0B123E18E5D6A2">
|
||||
<Properties>
|
||||
<string name="Name">BreakBlock</string>
|
||||
</Properties>
|
||||
</Item>
|
||||
</roblox>
|
||||
7
src/ReplicatedStorage/Remotes/PlaceBlock.rbxmx
Normal file
7
src/ReplicatedStorage/Remotes/PlaceBlock.rbxmx
Normal file
@@ -0,0 +1,7 @@
|
||||
<roblox version="4">
|
||||
<Item class="RemoteEvent" referent="RBX3F7B7B3E7E5C4D8B9A9B3D8C76E8C0A1">
|
||||
<Properties>
|
||||
<string name="Name">PlaceBlock</string>
|
||||
</Properties>
|
||||
</Item>
|
||||
</roblox>
|
||||
4
src/ReplicatedStorage/Remotes/init.meta.json
Normal file
4
src/ReplicatedStorage/Remotes/init.meta.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"className": "Folder",
|
||||
"ignoreUnknownInstances": true
|
||||
}
|
||||
@@ -39,17 +39,20 @@ local function propogateNeighboringBlockChanges(cx, cy, cz, x, y, z)
|
||||
local d = c.data[`{x},{y},{z}`]
|
||||
if not d then return end
|
||||
|
||||
task.synchronize()
|
||||
|
||||
if c:IsBlockRenderable(x, y, z) then
|
||||
if c.instance:FindFirstChild(`{x},{y},{z}`) then return end
|
||||
task.synchronize()
|
||||
local block = BlockManager:GetBlockRotated(d.id, util.RotationStringToNormalId(d.state["r"] or "f"), d.state)
|
||||
block.Name = `{x},{y},{z}`
|
||||
block:PivotTo(util.ChunkPosToCFrame(c.pos, Vector3.new(x, y, z)))
|
||||
block.Parent = c.instance
|
||||
task.desynchronize()
|
||||
else
|
||||
if c.instance:FindFirstChild(`{x},{y},{z}`) then
|
||||
c.instance:FindFirstChild(`{x},{y},{z}`):Destroy()
|
||||
local existing = c.instance:FindFirstChild(`{x},{y},{z}`)
|
||||
if existing then
|
||||
task.synchronize()
|
||||
existing:Destroy()
|
||||
task.desynchronize()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -28,6 +28,55 @@ local function findParent(i: Instance): Instance
|
||||
end
|
||||
|
||||
local Mouse: Mouse = nil
|
||||
local lastNormalId: Enum.NormalId? = nil
|
||||
|
||||
local function normalIdToOffset(normal: Enum.NormalId): Vector3
|
||||
if normal == Enum.NormalId.Top then
|
||||
return Vector3.new(0, 1, 0)
|
||||
elseif normal == Enum.NormalId.Bottom then
|
||||
return Vector3.new(0, -1, 0)
|
||||
elseif normal == Enum.NormalId.Left then
|
||||
return Vector3.new(-1, 0, 0)
|
||||
elseif normal == Enum.NormalId.Right then
|
||||
return Vector3.new(1, 0, 0)
|
||||
elseif normal == Enum.NormalId.Back then
|
||||
return Vector3.new(0, 0, 1)
|
||||
elseif normal == Enum.NormalId.Front then
|
||||
return Vector3.new(0, 0, -1)
|
||||
end
|
||||
return Vector3.new(0, 0, 0)
|
||||
end
|
||||
|
||||
local function offsetChunkBlock(chunk: Vector3, block: Vector3, offset: Vector3)
|
||||
local cx, cy, cz = chunk.X, chunk.Y, chunk.Z
|
||||
local bx, by, bz = block.X + offset.X, block.Y + offset.Y, block.Z + offset.Z
|
||||
|
||||
if bx < 1 then
|
||||
bx = 8
|
||||
cx -= 1
|
||||
elseif bx > 8 then
|
||||
bx = 1
|
||||
cx += 1
|
||||
end
|
||||
|
||||
if by < 1 then
|
||||
by = 8
|
||||
cy -= 1
|
||||
elseif by > 8 then
|
||||
by = 1
|
||||
cy += 1
|
||||
end
|
||||
|
||||
if bz < 1 then
|
||||
bz = 8
|
||||
cz -= 1
|
||||
elseif bz > 8 then
|
||||
bz = 1
|
||||
cz += 1
|
||||
end
|
||||
|
||||
return Vector3.new(cx, cy, cz), Vector3.new(bx, by, bz)
|
||||
end
|
||||
|
||||
-- Gets the block and normalid of the block (and surface) the player is looking at
|
||||
function PlacementManager:Raycast()
|
||||
@@ -40,6 +89,7 @@ function PlacementManager:Raycast()
|
||||
if not objLookingAt then
|
||||
PlacementManager.SelectionBox.Adornee = nil
|
||||
script.RaycastResult.Value = nil
|
||||
lastNormalId = nil
|
||||
return
|
||||
end
|
||||
|
||||
@@ -48,10 +98,12 @@ function PlacementManager:Raycast()
|
||||
if parent:GetAttribute("ns") == true then
|
||||
PlacementManager.SelectionBox.Adornee = nil
|
||||
script.RaycastResult.Value = nil
|
||||
lastNormalId = nil
|
||||
return
|
||||
end
|
||||
PlacementManager.SelectionBox.Adornee = parent
|
||||
script.RaycastResult.Value = parent
|
||||
lastNormalId = dir
|
||||
return parent, dir
|
||||
end
|
||||
|
||||
@@ -59,16 +111,17 @@ function PlacementManager:RaycastGetResult()
|
||||
return script.RaycastResult.Value
|
||||
end
|
||||
|
||||
local placeRemote = game:GetService("ReplicatedStorage").PlaceBlock
|
||||
local breakRemote = game:GetService("ReplicatedStorage").BreakBlock
|
||||
local remotes = game:GetService("ReplicatedStorage"):WaitForChild("Remotes")
|
||||
local placeRemote = remotes:WaitForChild("PlaceBlock")
|
||||
local breakRemote = remotes:WaitForChild("BreakBlock")
|
||||
local tickRemote = game:GetService("ReplicatedStorage").Tick
|
||||
|
||||
-- FIRES REMOTE
|
||||
function PlacementManager:PlaceBlock(cx, cy, cz, x, y, z, blockData)
|
||||
function PlacementManager:PlaceBlock(cx, cy, cz, x, y, z, blockId: string)
|
||||
--print("placeblock")
|
||||
--local chunk = ChunkManager:GetChunk(cx, cy, cz)
|
||||
--chunk:CreateBlock(x, y, z, blockData)
|
||||
placeRemote:FireServer(cx, cy, cz, x, y, z, blockData)
|
||||
placeRemote:FireServer(cx, cy, cz, x, y, z, blockId)
|
||||
end
|
||||
|
||||
-- FIRES REMOTE
|
||||
@@ -97,11 +150,13 @@ function PlacementManager:GetBlockAtMouse(): nil | {chunk:Vector3, block: Vector
|
||||
if selectedPart == nil then
|
||||
PlacementManager.SelectionBox.Adornee = nil
|
||||
script.RaycastResult.Value = nil
|
||||
lastNormalId = nil
|
||||
return nil
|
||||
end
|
||||
if not selectedPart.Parent then
|
||||
PlacementManager.SelectionBox.Adornee = nil
|
||||
script.RaycastResult.Value = nil
|
||||
lastNormalId = nil
|
||||
return nil
|
||||
end
|
||||
local chunkCoords = Util.BlockPosStringToCoords(selectedPart.Parent.Name)
|
||||
@@ -114,6 +169,32 @@ function PlacementManager:GetBlockAtMouse(): nil | {chunk:Vector3, block: Vector
|
||||
|
||||
end
|
||||
|
||||
function PlacementManager:GetTargetAtMouse(): nil | {chunk:Vector3, block: Vector3, normal: Enum.NormalId}
|
||||
local hit = PlacementManager:GetBlockAtMouse()
|
||||
if not hit or not lastNormalId then
|
||||
return nil
|
||||
end
|
||||
|
||||
return {
|
||||
chunk = hit.chunk,
|
||||
block = hit.block,
|
||||
normal = lastNormalId
|
||||
}
|
||||
end
|
||||
|
||||
function PlacementManager:GetPlacementAtMouse(): nil | {chunk:Vector3, block: Vector3}
|
||||
local hit = PlacementManager:GetTargetAtMouse()
|
||||
if not hit then
|
||||
return nil
|
||||
end
|
||||
local offset = normalIdToOffset(hit.normal)
|
||||
local placeChunk, placeBlock = offsetChunkBlock(hit.chunk, hit.block, offset)
|
||||
return {
|
||||
chunk = placeChunk,
|
||||
block = placeBlock
|
||||
}
|
||||
end
|
||||
|
||||
function PlacementManager:Init()
|
||||
game:GetService("RunService").Heartbeat:Connect(function()
|
||||
local a,b = pcall(function()
|
||||
|
||||
@@ -14,6 +14,10 @@ TerrainGen.ServerChunkCache = {} :: {[typeof("")]: typeof(Chunk.new(0,0,0))}
|
||||
|
||||
-- Load a chunk from the DataStore or generate it if not found
|
||||
function TerrainGen:GetChunk(x, y, z)
|
||||
local key = `{x},{y},{z}`
|
||||
if TerrainGen.ServerChunkCache[key] then
|
||||
return TerrainGen.ServerChunkCache[key]
|
||||
end
|
||||
|
||||
-- Generate a new chunk if it doesn't exist
|
||||
local chunk = Chunk.new(x, y, z)
|
||||
@@ -38,6 +42,7 @@ function TerrainGen:GetChunk(x, y, z)
|
||||
end
|
||||
end
|
||||
|
||||
TerrainGen.ServerChunkCache[key] = chunk
|
||||
return chunk
|
||||
end
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
local Shared = ReplicatedStorage:WaitForChild("Shared")
|
||||
local ModsFolder = ReplicatedStorage:WaitForChild("Mods")
|
||||
|
||||
local PlacementManager = require(Shared.PlacementManager)
|
||||
local Util = require(Shared.Util)
|
||||
local TG = require("./ServerChunkManager/TerrainGen")
|
||||
|
||||
do
|
||||
@@ -64,11 +64,67 @@ ReplicatedStorage.RecieveChunkPacket.OnServerInvoke = function(plr: Player, x: n
|
||||
end
|
||||
|
||||
local tickRemote = ReplicatedStorage.Tick
|
||||
local remotes = ReplicatedStorage:WaitForChild("Remotes")
|
||||
local placeRemote = remotes:WaitForChild("PlaceBlock")
|
||||
local breakRemote = remotes:WaitForChild("BreakBlock")
|
||||
local blocksFolder = ReplicatedStorage:WaitForChild("Blocks")
|
||||
local function propogate(a, cx, cy, cz, x, y, z, bd)
|
||||
task.synchronize()
|
||||
tickRemote:FireAllClients(a, cx, cy, cz, x, y, z, bd)
|
||||
task.desynchronize()
|
||||
end
|
||||
|
||||
ReplicatedStorage.PlaceBlock.OnServerEvent:Connect(function(player, cx, cy, cz, x, y, z, blockData)
|
||||
local MAX_REACH = 24
|
||||
local blockIdMap = {}
|
||||
|
||||
local function rebuildBlockIdMap()
|
||||
table.clear(blockIdMap)
|
||||
for _, block in ipairs(blocksFolder:GetChildren()) do
|
||||
local id = block:GetAttribute("n")
|
||||
if id ~= nil then
|
||||
blockIdMap[id] = id
|
||||
blockIdMap[tostring(id)] = id
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
rebuildBlockIdMap()
|
||||
blocksFolder.ChildAdded:Connect(rebuildBlockIdMap)
|
||||
blocksFolder.ChildRemoved:Connect(rebuildBlockIdMap)
|
||||
|
||||
local function getPlayerPosition(player: Player): Vector3?
|
||||
local character = player.Character
|
||||
if not character then
|
||||
return nil
|
||||
end
|
||||
local root = character:FindFirstChild("HumanoidRootPart")
|
||||
if not root then
|
||||
return nil
|
||||
end
|
||||
return root.Position
|
||||
end
|
||||
|
||||
local function isWithinReach(player: Player, cx: number, cy: number, cz: number, x: number, y: number, z: number): boolean
|
||||
local playerPos = getPlayerPosition(player)
|
||||
if not playerPos then
|
||||
return false
|
||||
end
|
||||
local blockPos = Util.ChunkPosToCFrame(Vector3.new(cx, cy, cz), Vector3.new(x, y, z)).Position
|
||||
return (blockPos - playerPos).Magnitude <= MAX_REACH
|
||||
end
|
||||
|
||||
local function resolveBlockId(blockId: any): string | number | nil
|
||||
return blockIdMap[blockId]
|
||||
end
|
||||
|
||||
local function getServerChunk(cx: number, cy: number, cz: number)
|
||||
task.desynchronize()
|
||||
local chunk = TG:GetChunk(cx, cy, cz)
|
||||
task.synchronize()
|
||||
return chunk
|
||||
end
|
||||
|
||||
placeRemote.OnServerEvent:Connect(function(player, cx, cy, cz, x, y, z, blockId)
|
||||
--print("place",player, cx, cy, cz, x, y, z, blockData)
|
||||
|
||||
if typeof(cx) ~= "number" or typeof(cy) ~= "number" or typeof(cz) ~= "number" then
|
||||
@@ -77,16 +133,33 @@ ReplicatedStorage.PlaceBlock.OnServerEvent:Connect(function(player, cx, cy, cz,
|
||||
if typeof(x) ~= "number" or typeof(y) ~= "number" or typeof(z) ~= "number" then
|
||||
return
|
||||
end
|
||||
if x < 1 or x > 8 or y < 1 or y > 8 or z < 1 or z > 8 then
|
||||
return
|
||||
end
|
||||
if math.abs(cx) > MAX_CHUNK_DIST or math.abs(cy) > MAX_CHUNK_DIST or math.abs(cz) > MAX_CHUNK_DIST then
|
||||
--return
|
||||
end
|
||||
if not isWithinReach(player, cx, cy, cz, x, y, z) then
|
||||
return
|
||||
end
|
||||
local resolvedId = resolveBlockId(blockId)
|
||||
if not resolvedId then
|
||||
return
|
||||
end
|
||||
|
||||
--local chunk = TG:GetChunk(cx, cy, cz)
|
||||
--PlacementManager:PlaceBlockLocal(cx, cy, cz, x, y, z, blockData)
|
||||
propogate("B_C", cx, cy, cz, x, y, z, blockData)
|
||||
local chunk = getServerChunk(cx, cy, cz)
|
||||
if chunk:GetBlockAt(x, y, z) then
|
||||
return
|
||||
end
|
||||
local data = {
|
||||
id = resolvedId,
|
||||
state = {}
|
||||
}
|
||||
chunk:CreateBlock(x, y, z, data)
|
||||
propogate("B_C", cx, cy, cz, x, y, z, data)
|
||||
end)
|
||||
|
||||
ReplicatedStorage.BreakBlock.OnServerEvent:Connect(function(player, cx, cy, cz, x, y, z)
|
||||
breakRemote.OnServerEvent:Connect(function(player, cx, cy, cz, x, y, z)
|
||||
--print("del",player, cx, cy, cz, x, y, z)
|
||||
|
||||
if typeof(cx) ~= "number" or typeof(cy) ~= "number" or typeof(cz) ~= "number" then
|
||||
@@ -95,13 +168,22 @@ ReplicatedStorage.BreakBlock.OnServerEvent:Connect(function(player, cx, cy, cz,
|
||||
if typeof(x) ~= "number" or typeof(y) ~= "number" or typeof(z) ~= "number" then
|
||||
return
|
||||
end
|
||||
if x < 1 or x > 8 or y < 1 or y > 8 or z < 1 or z > 8 then
|
||||
return
|
||||
end
|
||||
if math.abs(cx) > MAX_CHUNK_DIST or math.abs(cy) > MAX_CHUNK_DIST or math.abs(cz) > MAX_CHUNK_DIST then
|
||||
return
|
||||
end
|
||||
if not isWithinReach(player, cx, cy, cz, x, y, z) then
|
||||
return
|
||||
end
|
||||
|
||||
--local chunk = TG:GetChunk(cx, cy, cz)
|
||||
--PlacementManager:BreakBlockLocal(cx, cy, cz, x, y, z)
|
||||
local chunk = getServerChunk(cx, cy, cz)
|
||||
if not chunk:GetBlockAt(x, y, z) then
|
||||
return
|
||||
end
|
||||
chunk:RemoveBlock(x, y, z)
|
||||
propogate("B_D", cx, cy, cz, x, y, z, 0)
|
||||
end)
|
||||
|
||||
task.desynchronize()
|
||||
task.desynchronize()
|
||||
|
||||
@@ -5,7 +5,6 @@ end
|
||||
local ui = script.Parent
|
||||
|
||||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
local UIS = game:GetService("UserInputService")
|
||||
|
||||
ReplicatedStorage:WaitForChild("Objects"):WaitForChild("MLLoaded")
|
||||
|
||||
@@ -18,22 +17,6 @@ cd.Parent = game:GetService("Workspace"):FindFirstChildOfClass("Terrain")
|
||||
sky.Parent = game:GetService("Workspace"):FindFirstChildOfClass("Terrain")
|
||||
base.Parent = game:GetService("Workspace"):FindFirstChildOfClass("Terrain")
|
||||
|
||||
local PM = require(ReplicatedStorage.Shared.PlacementManager)
|
||||
|
||||
UIS.InputEnded:Connect(function(input: InputObject, gameProcessedEvent: boolean)
|
||||
if input.UserInputType == Enum.UserInputType.MouseButton1 then
|
||||
local mouseBlock = PM:GetBlockAtMouse()
|
||||
if not mouseBlock then return end
|
||||
PM:BreakBlock(mouseBlock.chunk.X, mouseBlock.chunk.Y, mouseBlock.chunk.Z, mouseBlock.block.X, mouseBlock.block.Y, mouseBlock.block.Z)
|
||||
elseif input.UserInputType == Enum.UserInputType.MouseButton2 then
|
||||
local mouseBlock = PM:GetBlockAtMouse()
|
||||
if not mouseBlock then return end
|
||||
PM:PlaceBlock(mouseBlock.chunk.X, mouseBlock.chunk.Y, mouseBlock.chunk.Z, mouseBlock.block.X, mouseBlock.block.Y, mouseBlock.block.Z, {
|
||||
id = 2,
|
||||
state = {}
|
||||
})
|
||||
end
|
||||
end)
|
||||
|
||||
game:GetService("RunService").RenderStepped:Connect(function(dt)
|
||||
local fps = math.round(1/dt)
|
||||
@@ -76,4 +59,4 @@ game:GetService("RunService").RenderStepped:Connect(function(dt)
|
||||
)
|
||||
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
if not game:IsLoaded() then
|
||||
game.Loaded:Wait()
|
||||
end
|
||||
|
||||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
local UIS = game:GetService("UserInputService")
|
||||
|
||||
ReplicatedStorage:WaitForChild("Objects"):WaitForChild("MLLoaded")
|
||||
|
||||
local blocksFolder = ReplicatedStorage:WaitForChild("Blocks")
|
||||
local PM = require(ReplicatedStorage.Shared.PlacementManager)
|
||||
|
||||
local HOTBAR_SIZE = 9
|
||||
local hotbar = table.create(HOTBAR_SIZE)
|
||||
local selectedSlot = 1
|
||||
|
||||
local keyToSlot = {
|
||||
[Enum.KeyCode.One] = 1,
|
||||
[Enum.KeyCode.Two] = 2,
|
||||
[Enum.KeyCode.Three] = 3,
|
||||
[Enum.KeyCode.Four] = 4,
|
||||
[Enum.KeyCode.Five] = 5,
|
||||
[Enum.KeyCode.Six] = 6,
|
||||
[Enum.KeyCode.Seven] = 7,
|
||||
[Enum.KeyCode.Eight] = 8,
|
||||
[Enum.KeyCode.Nine] = 9,
|
||||
}
|
||||
|
||||
local function rebuildHotbar()
|
||||
local ids = {}
|
||||
for _, block in ipairs(blocksFolder:GetChildren()) do
|
||||
local id = block:GetAttribute("n")
|
||||
if id ~= nil then
|
||||
table.insert(ids, tostring(id))
|
||||
end
|
||||
end
|
||||
|
||||
table.sort(ids)
|
||||
for i = 1, HOTBAR_SIZE do
|
||||
hotbar[i] = ids[i] or ""
|
||||
end
|
||||
selectedSlot = math.clamp(selectedSlot, 1, HOTBAR_SIZE)
|
||||
end
|
||||
|
||||
local function getSelectedBlockId(): string?
|
||||
local id = hotbar[selectedSlot]
|
||||
if id == "" then
|
||||
return nil
|
||||
end
|
||||
return id
|
||||
end
|
||||
|
||||
local function setSelectedSlot(slot: number)
|
||||
if slot < 1 or slot > HOTBAR_SIZE then
|
||||
return
|
||||
end
|
||||
selectedSlot = slot
|
||||
end
|
||||
|
||||
rebuildHotbar()
|
||||
blocksFolder.ChildAdded:Connect(rebuildHotbar)
|
||||
blocksFolder.ChildRemoved:Connect(rebuildHotbar)
|
||||
|
||||
UIS.InputBegan:Connect(function(input: InputObject, gameProcessedEvent: boolean)
|
||||
if gameProcessedEvent then
|
||||
return
|
||||
end
|
||||
|
||||
local slot = keyToSlot[input.KeyCode]
|
||||
if slot then
|
||||
setSelectedSlot(slot)
|
||||
return
|
||||
end
|
||||
|
||||
if input.UserInputType == Enum.UserInputType.MouseButton1 then
|
||||
local mouseBlock = PM:GetBlockAtMouse()
|
||||
if not mouseBlock then
|
||||
return
|
||||
end
|
||||
PM:BreakBlock(mouseBlock.chunk.X, mouseBlock.chunk.Y, mouseBlock.chunk.Z, mouseBlock.block.X, mouseBlock.block.Y, mouseBlock.block.Z)
|
||||
elseif input.UserInputType == Enum.UserInputType.MouseButton2 then
|
||||
local mouseBlock = PM:GetPlacementAtMouse()
|
||||
if not mouseBlock then
|
||||
return
|
||||
end
|
||||
local blockId = getSelectedBlockId()
|
||||
if not blockId then
|
||||
return
|
||||
end
|
||||
PM:PlaceBlock(mouseBlock.chunk.X, mouseBlock.chunk.Y, mouseBlock.chunk.Z, mouseBlock.block.X, mouseBlock.block.Y, mouseBlock.block.Z, blockId)
|
||||
end
|
||||
end)
|
||||
@@ -3,6 +3,7 @@ if not game:IsLoaded() then
|
||||
end
|
||||
|
||||
pcall(function()
|
||||
task.synchronize()
|
||||
game:GetService("Workspace"):WaitForChild("$blockscraft_server",5):Destroy()
|
||||
end)
|
||||
|
||||
@@ -22,4 +23,4 @@ end
|
||||
do
|
||||
local CM = require(ReplicatedStorage:WaitForChild("Shared"):WaitForChild("ChunkManager"))
|
||||
CM:Init()
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user