add custom chess engine
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
/// <summary>The available chess engine implementations.</summary>
|
||||
public enum ChessEngineKind
|
||||
{
|
||||
Stockfish,
|
||||
Custom
|
||||
}
|
||||
|
||||
/// <summary>Configuration selecting which <see cref="IChessEngine"/> to use.</summary>
|
||||
public sealed class ChessEngineOptions
|
||||
{
|
||||
public const string SectionName = "ChessEngine";
|
||||
|
||||
public ChessEngineKind Engine { get; set; } = ChessEngineKind.Stockfish;
|
||||
}
|
||||
|
||||
/// <summary>Creates the configured <see cref="IChessEngine"/> per game.</summary>
|
||||
public sealed class ChessEngineFactory(IOptions<ChessEngineOptions> options) : IChessEngineFactory
|
||||
{
|
||||
private readonly ChessEngineKind _kind = options.Value.Engine;
|
||||
|
||||
public IChessEngine Create(int skill) => _kind switch
|
||||
{
|
||||
ChessEngineKind.Custom => new CustomChessEngine(skill),
|
||||
ChessEngineKind.Stockfish => new Stockfish(skill),
|
||||
_ => throw new InvalidOperationException($"Unknown chess engine '{_kind}'.")
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using JoshHeaps.Net.Models;
|
||||
using System.Text;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
public static class ChessEngineHelpers
|
||||
{
|
||||
public static MoveDto ToMoveDto(
|
||||
this string uci,
|
||||
GameState gameState,
|
||||
Guid playerId)
|
||||
{
|
||||
int fCol = uci[0] - 'a', fRow = 7 - (uci[1] - '1');
|
||||
int tCol = uci[2] - 'a', tRow = 7 - (uci[3] - '1');
|
||||
|
||||
var piece = gameState.Board[fRow, fCol]
|
||||
?? throw new Exception("No piece at source square");
|
||||
|
||||
PieceType? promo = uci.Length == 5 ? uci[4] switch
|
||||
{
|
||||
'q' => PieceType.Queen,
|
||||
'r' => PieceType.Rook,
|
||||
'b' => PieceType.Bishop,
|
||||
'n' => PieceType.Knight,
|
||||
_ => null
|
||||
} : null;
|
||||
|
||||
return new MoveDto
|
||||
{
|
||||
GameId = gameState.GameId,
|
||||
PlayerId = playerId,
|
||||
PieceId = piece.Id,
|
||||
TargetRow = tRow,
|
||||
TargetCol = tCol,
|
||||
PromotionChoice = promo,
|
||||
SourceCol = fCol,
|
||||
SourceRow = fRow,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert a 2-D board array (rank 8 = row 0, file a = col 0) to a FEN string.
|
||||
/// Only piece placement + active colour + castling are computed; the rest use
|
||||
/// safe defaults (-, 0, 1). That is all the engine needs.
|
||||
/// </summary>
|
||||
public static string ToFen(this GameState gs)
|
||||
{
|
||||
var sb = new StringBuilder(64);
|
||||
|
||||
/* 1) piece placement */
|
||||
for (int row = 0; row < 8; row++)
|
||||
{
|
||||
int empty = 0;
|
||||
|
||||
for (int col = 0; col < 8; col++)
|
||||
{
|
||||
var p = gs.Board[row, col];
|
||||
|
||||
if (p is null)
|
||||
{
|
||||
empty++;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (empty > 0) { sb.Append(empty); empty = 0; }
|
||||
sb.Append(ToFenChar(p)); // ← unchanged helper
|
||||
}
|
||||
}
|
||||
|
||||
if (empty > 0) sb.Append(empty);
|
||||
if (row < 7) sb.Append('/');
|
||||
}
|
||||
|
||||
/* 2) active colour */
|
||||
sb.Append(gs.CurrentPlayer == PieceColor.White ? " w " : " b ");
|
||||
|
||||
/* 3) castling rights (from GameState flags) */
|
||||
sb.Append(GetCastlingFlags(gs));
|
||||
|
||||
/* 4) en-passant target square */
|
||||
sb.Append(' ');
|
||||
sb.Append(gs.EnPassantTarget.HasValue
|
||||
? Alg(gs.EnPassantTarget.Value)
|
||||
: "-");
|
||||
|
||||
/* 5-6) half-move clock + full-move number */
|
||||
int fullMoves = gs.MoveHistory.Count / 2 + 1;
|
||||
sb.Append(" 0 ").Append(fullMoves);
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/* ---------- helpers ---------- */
|
||||
|
||||
private static string GetCastlingFlags(GameState gs)
|
||||
{
|
||||
var flags = new StringBuilder(4);
|
||||
|
||||
if (gs.WhiteCanCastleKingside) flags.Append('K');
|
||||
if (gs.WhiteCanCastleQueenside) flags.Append('Q');
|
||||
if (gs.BlackCanCastleKingside) flags.Append('k');
|
||||
if (gs.BlackCanCastleQueenside) flags.Append('q');
|
||||
|
||||
return flags.Length == 0 ? "-" : flags.ToString();
|
||||
}
|
||||
|
||||
private static string Alg(Position p)
|
||||
{
|
||||
char file = (char)('a' + p.Col);
|
||||
int rank = 8 - p.Row;
|
||||
return $"{file}{rank}";
|
||||
}
|
||||
|
||||
private static char ToFenChar(ChessPiece p) => p switch
|
||||
{
|
||||
{ Type: PieceType.Pawn, Color: PieceColor.White } => 'P',
|
||||
{ Type: PieceType.Pawn, Color: PieceColor.Black } => 'p',
|
||||
{ Type: PieceType.Knight, Color: PieceColor.White } => 'N',
|
||||
{ Type: PieceType.Knight, Color: PieceColor.Black } => 'n',
|
||||
{ Type: PieceType.Bishop, Color: PieceColor.White } => 'B',
|
||||
{ Type: PieceType.Bishop, Color: PieceColor.Black } => 'b',
|
||||
{ Type: PieceType.Rook, Color: PieceColor.White } => 'R',
|
||||
{ Type: PieceType.Rook, Color: PieceColor.Black } => 'r',
|
||||
{ Type: PieceType.Queen, Color: PieceColor.White } => 'Q',
|
||||
{ Type: PieceType.Queen, Color: PieceColor.Black } => 'q',
|
||||
{ Type: PieceType.King, Color: PieceColor.White } => 'K',
|
||||
{ Type: PieceType.King, Color: PieceColor.Black } => 'k',
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(p))
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using JoshHeaps.Net.Hubs;
|
||||
using JoshHeaps.Net.Models;
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
/// <summary>
|
||||
/// Engine-agnostic glue for computer moves: get a move from the engine, apply it
|
||||
/// through the rules service, and broadcast it to the game's clients.
|
||||
/// </summary>
|
||||
public sealed class ComputerMoveOrchestrator(
|
||||
IHubContext<ChessHub> chessHub,
|
||||
IChessService chessService) : IComputerMoveOrchestrator
|
||||
{
|
||||
public async Task<(MoveDto move, MoveResultDto result)> PlayAsync(GameState state, IChessEngine engine)
|
||||
{
|
||||
var uci = await engine.GetBestMoveAsync(state.ToFen());
|
||||
|
||||
var move = uci.ToMoveDto(
|
||||
state,
|
||||
state.CurrentPlayer == PieceColor.White
|
||||
? state.WhitePlayerId
|
||||
: state.BlackPlayerId);
|
||||
|
||||
var result = chessService.MakeMove(state, move);
|
||||
|
||||
await chessHub.Clients.Group(state.GameId.ToString())
|
||||
.SendAsync("ReceiveMoveUpdate", state.GameId.ToString(), move, result);
|
||||
|
||||
return (move, result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
/// <summary>
|
||||
/// Middleman wrapper over the native custom chess engine (chess_engine.dll / libchess_engine.so).
|
||||
/// Shares <see cref="IChessEngine"/> with <see cref="Stockfish"/> so the two are swappable.
|
||||
/// The boundary contract is FEN string in, UCI move string out — identical to Stockfish.
|
||||
/// </summary>
|
||||
public sealed partial class CustomChessEngine : IChessEngine
|
||||
{
|
||||
private readonly EngineSafeHandle _handle;
|
||||
|
||||
public int Skill { get; }
|
||||
|
||||
public CustomChessEngine(int skill = 20)
|
||||
{
|
||||
Skill = skill;
|
||||
|
||||
var handle = NativeMethods.engine_create($"skill={skill}");
|
||||
|
||||
if (handle == IntPtr.Zero)
|
||||
throw new InvalidOperationException("Native chess engine failed to initialize (engine_create returned null).");
|
||||
|
||||
_handle = new EngineSafeHandle(handle);
|
||||
}
|
||||
|
||||
public Task<string> GetBestMoveAsync(string fen) => Task.Run(() => GetBestMove(fen));
|
||||
|
||||
private unsafe string GetBestMove(string fen)
|
||||
{
|
||||
const int bufferLength = 16; // longest UCI move is 5 chars ("e7e8q") + NUL
|
||||
byte* buffer = stackalloc byte[bufferLength];
|
||||
|
||||
var added = false;
|
||||
|
||||
try
|
||||
{
|
||||
_handle.DangerousAddRef(ref added);
|
||||
var code = NativeMethods.engine_best_move(_handle.DangerousGetHandle(), fen, buffer, bufferLength);
|
||||
|
||||
if (code != 0)
|
||||
throw new InvalidOperationException($"Native chess engine failed to produce a move for FEN '{fen}' (engine_best_move returned {code}).");
|
||||
|
||||
return Marshal.PtrToStringUTF8((IntPtr)buffer)
|
||||
?? throw new InvalidOperationException("Native chess engine returned an empty move.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (added)
|
||||
_handle.DangerousRelease();
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
_handle.Dispose();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Guarantees the native handle is released exactly once via engine_destroy.</summary>
|
||||
private sealed class EngineSafeHandle : SafeHandle
|
||||
{
|
||||
public EngineSafeHandle(IntPtr handle) : base(IntPtr.Zero, ownsHandle: true) => SetHandle(handle);
|
||||
|
||||
public override bool IsInvalid => handle == IntPtr.Zero;
|
||||
|
||||
protected override bool ReleaseHandle()
|
||||
{
|
||||
NativeMethods.engine_destroy(handle);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// P/Invoke surface for chess_engine.(dll|so). The resolver maps the logical name
|
||||
/// "chess_engine" to the platform binary in the Resources folder (mirrors Stockfish).
|
||||
/// </summary>
|
||||
private static partial class NativeMethods
|
||||
{
|
||||
private const string LibName = "chess_engine";
|
||||
|
||||
static NativeMethods() =>
|
||||
NativeLibrary.SetDllImportResolver(typeof(NativeMethods).Assembly, Resolve);
|
||||
|
||||
private static IntPtr Resolve(string libraryName, Assembly assembly, DllImportSearchPath? searchPath)
|
||||
{
|
||||
if (libraryName != LibName)
|
||||
return IntPtr.Zero;
|
||||
|
||||
var fileName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
|
||||
? "chess_engine.dll"
|
||||
: "libchess_engine.so";
|
||||
|
||||
var path = Path.Combine(AppContext.BaseDirectory, "Resources", fileName);
|
||||
|
||||
if (NativeLibrary.TryLoad(path, out var handle))
|
||||
return handle;
|
||||
|
||||
return NativeLibrary.Load(libraryName, assembly, searchPath);
|
||||
}
|
||||
|
||||
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
|
||||
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||
internal static partial IntPtr engine_create(string? options);
|
||||
|
||||
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
|
||||
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||
internal static unsafe partial int engine_best_move(IntPtr engine, string fen, byte* outBuffer, int outLength);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||
internal static partial void engine_destroy(IntPtr engine);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,19 @@
|
||||
using JoshHeaps.Net.Hubs;
|
||||
using JoshHeaps.Net.Models;
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
public sealed class Stockfish : IAsyncDisposable
|
||||
public sealed class Stockfish : IChessEngine
|
||||
{
|
||||
private readonly Process _p;
|
||||
private readonly StreamWriter _stdin;
|
||||
private readonly Channel<string> _stdout = Channel.CreateUnbounded<string>();
|
||||
private readonly int _skill;
|
||||
|
||||
public int Skill => _skill;
|
||||
|
||||
public Stockfish(int skill = 20, int hash = 256)
|
||||
{
|
||||
_skill = skill;
|
||||
@@ -28,13 +25,13 @@ public sealed class Stockfish : IAsyncDisposable
|
||||
: "stockfish-ubuntu-x86-64-sse41-popcnt"; // default: Linux
|
||||
|
||||
string exePath = Path.Combine(baseDir, "Resources", fileName);
|
||||
|
||||
|
||||
if (!File.Exists(exePath))
|
||||
{
|
||||
throw new FileNotFoundException($"Stockfish executable not found at {exePath}. " +
|
||||
"Ensure the file is present in the Resources folder of your project.");
|
||||
}
|
||||
|
||||
|
||||
Console.Write(exePath);
|
||||
|
||||
_p = new Process
|
||||
@@ -109,145 +106,4 @@ public sealed class Stockfish : IAsyncDisposable
|
||||
await _p.WaitForExitAsync();
|
||||
_p.Dispose();
|
||||
}
|
||||
|
||||
public async Task MakeMove(GameState state, IHubContext<ChessHub> chessHub, IChessService chessService)
|
||||
{
|
||||
var move = await GetBestMoveAsync(state.ToFen());
|
||||
|
||||
var moveDto = move.ToMoveDto(
|
||||
state,
|
||||
state.CurrentPlayer == PieceColor.White
|
||||
? state.WhitePlayerId
|
||||
: state.BlackPlayerId);
|
||||
|
||||
var result = chessService.MakeMove(state, moveDto);
|
||||
|
||||
await chessHub.Clients.Group(state.GameId.ToString()).SendAsync("ReceiveMoveUpdate", state.GameId.ToString(), moveDto, result);
|
||||
}
|
||||
}
|
||||
|
||||
public static class StockfishHelpers
|
||||
{
|
||||
public static MoveDto ToMoveDto(
|
||||
this string uci,
|
||||
GameState gameState,
|
||||
Guid playerId)
|
||||
{
|
||||
int fCol = uci[0] - 'a', fRow = 7 - (uci[1] - '1');
|
||||
int tCol = uci[2] - 'a', tRow = 7 - (uci[3] - '1');
|
||||
|
||||
var piece = gameState.Board[fRow, fCol]
|
||||
?? throw new Exception("No piece at source square");
|
||||
|
||||
PieceType? promo = uci.Length == 5 ? uci[4] switch
|
||||
{
|
||||
'q' => PieceType.Queen,
|
||||
'r' => PieceType.Rook,
|
||||
'b' => PieceType.Bishop,
|
||||
'n' => PieceType.Knight,
|
||||
_ => null
|
||||
} : null;
|
||||
|
||||
return new MoveDto
|
||||
{
|
||||
GameId = gameState.GameId,
|
||||
PlayerId = playerId,
|
||||
PieceId = piece.Id,
|
||||
TargetRow = tRow,
|
||||
TargetCol = tCol,
|
||||
PromotionChoice = promo,
|
||||
SourceCol = fCol,
|
||||
SourceRow = fRow,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert a 2-D board array (rank 8 = row 0, file a = col 0) to a FEN string.
|
||||
/// Only piece placement + active colour + castling are computed; the rest use
|
||||
/// safe defaults (-, 0, 1). That is all Stockfish needs.
|
||||
/// </summary>
|
||||
public static string ToFen(this GameState gs)
|
||||
{
|
||||
var sb = new StringBuilder(64);
|
||||
|
||||
/* 1) piece placement */
|
||||
for (int row = 0; row < 8; row++)
|
||||
{
|
||||
int empty = 0;
|
||||
|
||||
for (int col = 0; col < 8; col++)
|
||||
{
|
||||
var p = gs.Board[row, col];
|
||||
|
||||
if (p is null)
|
||||
{
|
||||
empty++;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (empty > 0) { sb.Append(empty); empty = 0; }
|
||||
sb.Append(ToFenChar(p)); // ← unchanged helper
|
||||
}
|
||||
}
|
||||
|
||||
if (empty > 0) sb.Append(empty);
|
||||
if (row < 7) sb.Append('/');
|
||||
}
|
||||
|
||||
/* 2) active colour */
|
||||
sb.Append(gs.CurrentPlayer == PieceColor.White ? " w " : " b ");
|
||||
|
||||
/* 3) castling rights (from GameState flags) */
|
||||
sb.Append(GetCastlingFlags(gs));
|
||||
|
||||
/* 4) en-passant target square */
|
||||
sb.Append(' ');
|
||||
sb.Append(gs.EnPassantTarget.HasValue
|
||||
? Alg(gs.EnPassantTarget.Value)
|
||||
: "-");
|
||||
|
||||
/* 5-6) half-move clock + full-move number */
|
||||
int fullMoves = gs.MoveHistory.Count / 2 + 1;
|
||||
sb.Append(" 0 ").Append(fullMoves);
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/* ---------- helpers ---------- */
|
||||
|
||||
private static string GetCastlingFlags(GameState gs)
|
||||
{
|
||||
var flags = new StringBuilder(4);
|
||||
|
||||
if (gs.WhiteCanCastleKingside) flags.Append('K');
|
||||
if (gs.WhiteCanCastleQueenside) flags.Append('Q');
|
||||
if (gs.BlackCanCastleKingside) flags.Append('k');
|
||||
if (gs.BlackCanCastleQueenside) flags.Append('q');
|
||||
|
||||
return flags.Length == 0 ? "-" : flags.ToString();
|
||||
}
|
||||
|
||||
private static string Alg(Position p)
|
||||
{
|
||||
char file = (char)('a' + p.Col);
|
||||
int rank = 8 - p.Row;
|
||||
return $"{file}{rank}";
|
||||
}
|
||||
|
||||
private static char ToFenChar(ChessPiece p) => p switch
|
||||
{
|
||||
{ Type: PieceType.Pawn, Color: PieceColor.White } => 'P',
|
||||
{ Type: PieceType.Pawn, Color: PieceColor.Black } => 'p',
|
||||
{ Type: PieceType.Knight, Color: PieceColor.White } => 'N',
|
||||
{ Type: PieceType.Knight, Color: PieceColor.Black } => 'n',
|
||||
{ Type: PieceType.Bishop, Color: PieceColor.White } => 'B',
|
||||
{ Type: PieceType.Bishop, Color: PieceColor.Black } => 'b',
|
||||
{ Type: PieceType.Rook, Color: PieceColor.White } => 'R',
|
||||
{ Type: PieceType.Rook, Color: PieceColor.Black } => 'r',
|
||||
{ Type: PieceType.Queen, Color: PieceColor.White } => 'Q',
|
||||
{ Type: PieceType.Queen, Color: PieceColor.Black } => 'q',
|
||||
{ Type: PieceType.King, Color: PieceColor.White } => 'K',
|
||||
{ Type: PieceType.King, Color: PieceColor.Black } => 'k',
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(p))
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace JoshHeaps.Net.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// An AI move-selection engine (e.g. Stockfish or the custom native engine).
|
||||
/// Selects a move for a position; it does not enforce rules or broadcast updates.
|
||||
/// Implementations own unmanaged resources, hence <see cref="IAsyncDisposable"/>.
|
||||
/// </summary>
|
||||
public interface IChessEngine : IAsyncDisposable
|
||||
{
|
||||
/// <summary>The engine's playing strength / search depth.</summary>
|
||||
int Skill { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns the engine's chosen move in UCI long-algebraic form (e.g. "e2e4", "e7e8q").
|
||||
/// </summary>
|
||||
/// <param name="fen">The current position as a FEN string.</param>
|
||||
/// <returns>The selected move as a UCI string.</returns>
|
||||
Task<string> GetBestMoveAsync(string fen);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace JoshHeaps.Net.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Creates <see cref="IChessEngine"/> instances, choosing the implementation from configuration.
|
||||
/// </summary>
|
||||
public interface IChessEngineFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new engine instance for a single game. The caller owns and disposes it.
|
||||
/// </summary>
|
||||
/// <param name="skill">The desired playing strength / search depth.</param>
|
||||
/// <returns>A new, owned <see cref="IChessEngine"/>.</returns>
|
||||
IChessEngine Create(int skill);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using JoshHeaps.Net.Models;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Drives a computer move: asks the engine for a move, applies it through the rules
|
||||
/// service, and broadcasts the result to the game's clients.
|
||||
/// </summary>
|
||||
public interface IComputerMoveOrchestrator
|
||||
{
|
||||
/// <summary>
|
||||
/// Has the engine pick a move for the current position, applies it, and broadcasts it.
|
||||
/// </summary>
|
||||
/// <param name="state">The game to play a move in.</param>
|
||||
/// <param name="engine">The engine that selects the move.</param>
|
||||
/// <returns>The applied move and its result.</returns>
|
||||
Task<(MoveDto move, MoveResultDto result)> PlayAsync(GameState state, IChessEngine engine);
|
||||
}
|
||||
Reference in New Issue
Block a user