refactor(game): COUP-03 + CONV-02 encapsulate Options state behind setters
L'estat mutable d'Options (window, video, audio, locale, presets, paths) es mou a un anonymous namespace dins options.cpp. L'API pública del namespace passa a ser: - Getters const-ref: window(), video(), audio(), locale(), configVersion(), postfxPresets(), crtpiPresets(). - Setters específics que muten i persisteixen via saveToFile(): setWindowZoom, setWindowFullscreen, setVideoVSync, setVideoScalingMode, setVideoRenderInfoPosition, setVideoRenderInfoShowTime, setShaderEnabled, setActiveShaderName, setPostFXPreset, setCrtPiPreset. Els setters NO notifiquen els subsistemes: el caller (global_inputs.cpp) crida explícitament Screen::setWindowZoom, reapplyShader, etc. La part "observers" del audit es deixa WONTFIX justificat — notificar des d'Options invertiria la direcció Director→Screen/Audio i Options aprenria del layout dels subsistemes. CONV-02 tanca al pas: la part Options dels "globals sense observers" queda resolta amb la nova encapsulació. Map::* (l'altra meitat de CONV-02) ja s'havia tancat amb PATCH-07. Smoke tests 13/13 OK; log Game start → Bye! sota xvfb-run + SDL_VIDEODRIVER=dummy + SIGINT idèntic al baseline de main. clang-tidy i cppcheck nets sobre els fitxers modificats. Refs: docs/audit-game.md (COUP-03, CONV-02, INCONS-01). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
32dcf264c4
commit
a3b03b87be
5 files changed
+256
-160
No files matched your search
+157
-91
@@ -14,6 +14,21 @@
|
||||
|
||||
namespace {
|
||||
|
||||
// --- Estat mutable (privat al TU) ---
|
||||
// Encapsulat ací per impedir mutacions directes des de fora del namespace
|
||||
// Options:: (decisió COUP-03 / docs/audit-game.md). Els callers llegeixen
|
||||
// via getters const-ref i muten via setters específics que persisteixen.
|
||||
std::string g_config_version;
|
||||
std::string g_config_file_path;
|
||||
std::string g_postfx_file_path;
|
||||
std::string g_crtpi_file_path;
|
||||
std::string g_locale{Defaults::I18n::LOCALE};
|
||||
Options::Window g_window{};
|
||||
Options::Video g_video{};
|
||||
Options::Audio g_audio{};
|
||||
std::vector<Options::PostFXPreset> g_postfx_presets{};
|
||||
std::vector<Options::CrtPiPreset> g_crtpi_presets{};
|
||||
|
||||
// Taules enum ↔ string per a la serialització a YAML. La primera entrada
|
||||
// és el fallback quan l'enum no té nom conegut o el text no casa amb
|
||||
// cap entrada coneguda — per això `LETTERBOX`/`NEAREST`/`OFF` encapçalen.
|
||||
@@ -93,40 +108,40 @@ namespace {
|
||||
void parseWindow(const fkyaml::node& yaml) {
|
||||
if (!yaml.contains("window")) { return; }
|
||||
const auto& node = yaml["window"];
|
||||
readField(node, "zoom", Options::window.zoom);
|
||||
readField(node, "fullscreen", Options::window.fullscreen);
|
||||
readField(node, "zoom", g_window.zoom);
|
||||
readField(node, "fullscreen", g_window.fullscreen);
|
||||
}
|
||||
|
||||
void parseGpu(const fkyaml::node& video_node) {
|
||||
if (!video_node.contains("gpu")) { return; }
|
||||
const auto& node = video_node["gpu"];
|
||||
readField(node, "acceleration", Options::video.gpu.acceleration);
|
||||
readField(node, "preferred_driver", Options::video.gpu.preferred_driver);
|
||||
readField(node, "acceleration", g_video.gpu.acceleration);
|
||||
readField(node, "preferred_driver", g_video.gpu.preferred_driver);
|
||||
}
|
||||
|
||||
void parseShader(const fkyaml::node& video_node) {
|
||||
if (!video_node.contains("shader")) { return; }
|
||||
const auto& node = video_node["shader"];
|
||||
readField(node, "enabled", Options::video.shader.enabled);
|
||||
readField(node, "current_shader", Options::video.shader.current_shader);
|
||||
readField(node, "current_postfx_preset", Options::video.shader.current_postfx_preset);
|
||||
readField(node, "current_crtpi_preset", Options::video.shader.current_crtpi_preset);
|
||||
readField(node, "enabled", g_video.shader.enabled);
|
||||
readField(node, "current_shader", g_video.shader.current_shader);
|
||||
readField(node, "current_postfx_preset", g_video.shader.current_postfx_preset);
|
||||
readField(node, "current_crtpi_preset", g_video.shader.current_crtpi_preset);
|
||||
}
|
||||
|
||||
void parseVideo(const fkyaml::node& yaml) {
|
||||
if (!yaml.contains("video")) { return; }
|
||||
const auto& node = yaml["video"];
|
||||
readField(node, "vsync", Options::video.vsync);
|
||||
readField(node, "vsync", g_video.vsync);
|
||||
if (node.contains("scaling_mode")) {
|
||||
Options::video.scaling_mode = enumFromString(SCALING_MODE_NAMES, node["scaling_mode"].get_value<std::string>());
|
||||
g_video.scaling_mode = enumFromString(SCALING_MODE_NAMES, node["scaling_mode"].get_value<std::string>());
|
||||
}
|
||||
if (node.contains("texture_filter")) {
|
||||
Options::video.texture_filter = enumFromString(TEXTURE_FILTER_NAMES, node["texture_filter"].get_value<std::string>());
|
||||
g_video.texture_filter = enumFromString(TEXTURE_FILTER_NAMES, node["texture_filter"].get_value<std::string>());
|
||||
}
|
||||
if (node.contains("render_info_position")) {
|
||||
Options::video.render_info_position = enumFromString(RENDER_INFO_POSITION_NAMES, node["render_info_position"].get_value<std::string>());
|
||||
g_video.render_info_position = enumFromString(RENDER_INFO_POSITION_NAMES, node["render_info_position"].get_value<std::string>());
|
||||
}
|
||||
readField(node, "render_info_show_time", Options::video.render_info_show_time);
|
||||
readField(node, "render_info_show_time", g_video.render_info_show_time);
|
||||
parseGpu(node);
|
||||
parseShader(node);
|
||||
}
|
||||
@@ -134,22 +149,22 @@ namespace {
|
||||
void parseMusic(const fkyaml::node& audio_node) {
|
||||
if (!audio_node.contains("music")) { return; }
|
||||
const auto& node = audio_node["music"];
|
||||
readField(node, "enabled", Options::audio.music.enabled);
|
||||
readField(node, "volume", Options::audio.music.volume);
|
||||
readField(node, "enabled", g_audio.music.enabled);
|
||||
readField(node, "volume", g_audio.music.volume);
|
||||
}
|
||||
|
||||
void parseSound(const fkyaml::node& audio_node) {
|
||||
if (!audio_node.contains("sound")) { return; }
|
||||
const auto& node = audio_node["sound"];
|
||||
readField(node, "enabled", Options::audio.sound.enabled);
|
||||
readField(node, "volume", Options::audio.sound.volume);
|
||||
readField(node, "enabled", g_audio.sound.enabled);
|
||||
readField(node, "volume", g_audio.sound.volume);
|
||||
}
|
||||
|
||||
void parseAudio(const fkyaml::node& yaml) {
|
||||
if (!yaml.contains("audio")) { return; }
|
||||
const auto& node = yaml["audio"];
|
||||
readField(node, "enabled", Options::audio.enabled);
|
||||
readField(node, "volume", Options::audio.volume);
|
||||
readField(node, "enabled", g_audio.enabled);
|
||||
readField(node, "volume", g_audio.volume);
|
||||
parseMusic(node);
|
||||
parseSound(node);
|
||||
}
|
||||
@@ -423,14 +438,65 @@ namespace {
|
||||
|
||||
namespace Options {
|
||||
|
||||
// --- Getters de l'estat ---
|
||||
auto window() -> const Window& { return g_window; }
|
||||
auto video() -> const Video& { return g_video; }
|
||||
auto audio() -> const Audio& { return g_audio; }
|
||||
auto locale() -> const std::string& { return g_locale; }
|
||||
auto configVersion() -> const std::string& { return g_config_version; }
|
||||
auto postfxPresets() -> const std::vector<PostFXPreset>& { return g_postfx_presets; }
|
||||
auto crtpiPresets() -> const std::vector<CrtPiPreset>& { return g_crtpi_presets; }
|
||||
|
||||
// --- Setters: muten + persisteixen ---
|
||||
void setWindowZoom(int value) {
|
||||
g_window.zoom = value;
|
||||
saveToFile();
|
||||
}
|
||||
void setWindowFullscreen(bool value) {
|
||||
g_window.fullscreen = value;
|
||||
saveToFile();
|
||||
}
|
||||
void setVideoVSync(bool value) {
|
||||
g_video.vsync = value;
|
||||
saveToFile();
|
||||
}
|
||||
void setVideoScalingMode(Defaults::ScalingMode value) {
|
||||
g_video.scaling_mode = value;
|
||||
saveToFile();
|
||||
}
|
||||
void setVideoRenderInfoPosition(Defaults::RenderInfoPosition value) {
|
||||
g_video.render_info_position = value;
|
||||
saveToFile();
|
||||
}
|
||||
void setVideoRenderInfoShowTime(bool value) {
|
||||
g_video.render_info_show_time = value;
|
||||
saveToFile();
|
||||
}
|
||||
void setShaderEnabled(bool value) {
|
||||
g_video.shader.enabled = value;
|
||||
saveToFile();
|
||||
}
|
||||
void setActiveShaderName(const std::string& value) {
|
||||
g_video.shader.current_shader = value;
|
||||
saveToFile();
|
||||
}
|
||||
void setPostFXPreset(const std::string& value) {
|
||||
g_video.shader.current_postfx_preset = value;
|
||||
saveToFile();
|
||||
}
|
||||
void setCrtPiPreset(const std::string& value) {
|
||||
g_video.shader.current_crtpi_preset = value;
|
||||
saveToFile();
|
||||
}
|
||||
|
||||
void setConfigFile(const std::string& path) {
|
||||
config_file_path = path;
|
||||
g_config_file_path = path;
|
||||
}
|
||||
|
||||
auto loadFromFile() -> bool {
|
||||
if (config_file_path.empty()) { return false; }
|
||||
if (g_config_file_path.empty()) { return false; }
|
||||
|
||||
const std::string CONTENT = readFileOrEmpty(config_file_path);
|
||||
const std::string CONTENT = readFileOrEmpty(g_config_file_path);
|
||||
if (CONTENT.empty()) {
|
||||
// Primera execució: creem el fitxer amb els valors per defecte.
|
||||
return saveToFile();
|
||||
@@ -449,12 +515,12 @@ namespace Options {
|
||||
if (file_version != Texts::VERSION) {
|
||||
std::cout << "Options: config.yaml versió \"" << file_version
|
||||
<< "\" != \"" << Texts::VERSION << "\"; reescrivint amb defaults.\n";
|
||||
backupFile(config_file_path);
|
||||
backupFile(g_config_file_path);
|
||||
return saveToFile();
|
||||
}
|
||||
config_version = file_version;
|
||||
g_config_version = file_version;
|
||||
|
||||
if (yaml.contains("locale")) { Options::locale = yaml["locale"].get_value<std::string>(); }
|
||||
if (yaml.contains("locale")) { g_locale = yaml["locale"].get_value<std::string>(); }
|
||||
parseWindow(yaml);
|
||||
parseVideo(yaml);
|
||||
parseAudio(yaml);
|
||||
@@ -462,84 +528,84 @@ namespace Options {
|
||||
} catch (const std::exception& e) {
|
||||
std::cout << "Options: error parsejant config.yaml: " << e.what() << '\n';
|
||||
std::cout << "Options: reescrivint el fitxer amb defaults.\n";
|
||||
backupFile(config_file_path);
|
||||
backupFile(g_config_file_path);
|
||||
return saveToFile();
|
||||
}
|
||||
}
|
||||
|
||||
auto saveToFile() -> bool {
|
||||
if (config_file_path.empty()) { return false; }
|
||||
if (g_config_file_path.empty()) { return false; }
|
||||
|
||||
config_version = Texts::VERSION;
|
||||
g_config_version = Texts::VERSION;
|
||||
|
||||
std::ostringstream out;
|
||||
out << "# Configuració d'AEE 2026. Generat automàticament; editable a mà.\n";
|
||||
out << "version: \"" << config_version << "\"\n";
|
||||
out << "locale: \"" << locale << "\"\n\n";
|
||||
out << "version: \"" << g_config_version << "\"\n";
|
||||
out << "locale: \"" << g_locale << "\"\n\n";
|
||||
|
||||
out << "window:\n";
|
||||
out << " zoom: " << window.zoom << "\n";
|
||||
out << " fullscreen: " << (window.fullscreen ? "true" : "false") << "\n\n";
|
||||
out << " zoom: " << g_window.zoom << "\n";
|
||||
out << " fullscreen: " << (g_window.fullscreen ? "true" : "false") << "\n\n";
|
||||
|
||||
out << "video:\n";
|
||||
out << " vsync: " << (video.vsync ? "true" : "false") << "\n";
|
||||
out << " scaling_mode: \"" << enumToString(SCALING_MODE_NAMES, video.scaling_mode) << "\"\n";
|
||||
out << " texture_filter: \"" << enumToString(TEXTURE_FILTER_NAMES, video.texture_filter) << "\"\n";
|
||||
out << " render_info_position: \"" << enumToString(RENDER_INFO_POSITION_NAMES, video.render_info_position) << "\"\n";
|
||||
out << " render_info_show_time: " << (video.render_info_show_time ? "true" : "false") << "\n";
|
||||
out << " vsync: " << (g_video.vsync ? "true" : "false") << "\n";
|
||||
out << " scaling_mode: \"" << enumToString(SCALING_MODE_NAMES, g_video.scaling_mode) << "\"\n";
|
||||
out << " texture_filter: \"" << enumToString(TEXTURE_FILTER_NAMES, g_video.texture_filter) << "\"\n";
|
||||
out << " render_info_position: \"" << enumToString(RENDER_INFO_POSITION_NAMES, g_video.render_info_position) << "\"\n";
|
||||
out << " render_info_show_time: " << (g_video.render_info_show_time ? "true" : "false") << "\n";
|
||||
out << " gpu:\n";
|
||||
out << " acceleration: " << (video.gpu.acceleration ? "true" : "false") << "\n";
|
||||
out << " preferred_driver: \"" << video.gpu.preferred_driver << "\"\n";
|
||||
out << " acceleration: " << (g_video.gpu.acceleration ? "true" : "false") << "\n";
|
||||
out << " preferred_driver: \"" << g_video.gpu.preferred_driver << "\"\n";
|
||||
out << " shader:\n";
|
||||
out << " enabled: " << (video.shader.enabled ? "true" : "false") << "\n";
|
||||
out << " current_shader: \"" << video.shader.current_shader << "\"\n";
|
||||
out << " current_postfx_preset: \"" << video.shader.current_postfx_preset << "\"\n";
|
||||
out << " current_crtpi_preset: \"" << video.shader.current_crtpi_preset << "\"\n\n";
|
||||
out << " enabled: " << (g_video.shader.enabled ? "true" : "false") << "\n";
|
||||
out << " current_shader: \"" << g_video.shader.current_shader << "\"\n";
|
||||
out << " current_postfx_preset: \"" << g_video.shader.current_postfx_preset << "\"\n";
|
||||
out << " current_crtpi_preset: \"" << g_video.shader.current_crtpi_preset << "\"\n\n";
|
||||
|
||||
out << "audio:\n";
|
||||
out << " enabled: " << (audio.enabled ? "true" : "false") << "\n";
|
||||
out << " volume: " << audio.volume << "\n";
|
||||
out << " enabled: " << (g_audio.enabled ? "true" : "false") << "\n";
|
||||
out << " volume: " << g_audio.volume << "\n";
|
||||
out << " music:\n";
|
||||
out << " enabled: " << (audio.music.enabled ? "true" : "false") << "\n";
|
||||
out << " volume: " << audio.music.volume << "\n";
|
||||
out << " enabled: " << (g_audio.music.enabled ? "true" : "false") << "\n";
|
||||
out << " volume: " << g_audio.music.volume << "\n";
|
||||
out << " sound:\n";
|
||||
out << " enabled: " << (audio.sound.enabled ? "true" : "false") << "\n";
|
||||
out << " volume: " << audio.sound.volume << "\n";
|
||||
out << " enabled: " << (g_audio.sound.enabled ? "true" : "false") << "\n";
|
||||
out << " volume: " << g_audio.sound.volume << "\n";
|
||||
|
||||
return writeFile(config_file_path, out.str());
|
||||
return writeFile(g_config_file_path, out.str());
|
||||
}
|
||||
|
||||
auto buildScreenConfig() -> Screen::Config {
|
||||
return {
|
||||
.zoom = window.zoom,
|
||||
.fullscreen = window.fullscreen,
|
||||
.vsync = video.vsync,
|
||||
.scaling_mode = video.scaling_mode,
|
||||
.texture_filter = video.texture_filter,
|
||||
.gpu_acceleration = video.gpu.acceleration,
|
||||
.preferred_driver = video.gpu.preferred_driver,
|
||||
.zoom = g_window.zoom,
|
||||
.fullscreen = g_window.fullscreen,
|
||||
.vsync = g_video.vsync,
|
||||
.scaling_mode = g_video.scaling_mode,
|
||||
.texture_filter = g_video.texture_filter,
|
||||
.gpu_acceleration = g_video.gpu.acceleration,
|
||||
.preferred_driver = g_video.gpu.preferred_driver,
|
||||
};
|
||||
}
|
||||
|
||||
auto buildAudioConfig() -> ::Audio::Config {
|
||||
return {
|
||||
.enabled = audio.enabled,
|
||||
.volume = audio.volume,
|
||||
.music_enabled = audio.music.enabled,
|
||||
.music_volume = audio.music.volume,
|
||||
.sound_enabled = audio.sound.enabled,
|
||||
.sound_volume = audio.sound.volume,
|
||||
.enabled = g_audio.enabled,
|
||||
.volume = g_audio.volume,
|
||||
.music_enabled = g_audio.music.enabled,
|
||||
.music_volume = g_audio.music.volume,
|
||||
.sound_enabled = g_audio.sound.enabled,
|
||||
.sound_volume = g_audio.sound.volume,
|
||||
};
|
||||
}
|
||||
|
||||
void setPostFXFile(const std::string& path) {
|
||||
postfx_file_path = path;
|
||||
g_postfx_file_path = path;
|
||||
}
|
||||
|
||||
auto loadPostFXFromFile() -> bool {
|
||||
return loadPresetsFromFile<PostFXPreset>(
|
||||
postfx_file_path,
|
||||
postfx_presets,
|
||||
g_postfx_file_path,
|
||||
g_postfx_presets,
|
||||
&defaultPostFXPresets,
|
||||
&parsePostFXPreset,
|
||||
&savePostFXToFile,
|
||||
@@ -548,21 +614,21 @@ namespace Options {
|
||||
|
||||
auto savePostFXToFile() -> bool {
|
||||
return savePresetsToFile<PostFXPreset>(
|
||||
postfx_file_path,
|
||||
postfx_presets,
|
||||
g_postfx_file_path,
|
||||
g_postfx_presets,
|
||||
&writePostFXPreset,
|
||||
"Presets PostFX per a AEE 2026. Editable a mà.",
|
||||
"Els noms han de coincidir amb config.yaml > video.shader.current_postfx_preset.");
|
||||
}
|
||||
|
||||
void setCrtPiFile(const std::string& path) {
|
||||
crtpi_file_path = path;
|
||||
g_crtpi_file_path = path;
|
||||
}
|
||||
|
||||
auto loadCrtPiFromFile() -> bool {
|
||||
return loadPresetsFromFile<CrtPiPreset>(
|
||||
crtpi_file_path,
|
||||
crtpi_presets,
|
||||
g_crtpi_file_path,
|
||||
g_crtpi_presets,
|
||||
&defaultCrtPiPresets,
|
||||
&parseCrtPiPreset,
|
||||
&saveCrtPiToFile,
|
||||
@@ -571,29 +637,29 @@ namespace Options {
|
||||
|
||||
auto saveCrtPiToFile() -> bool {
|
||||
return savePresetsToFile<CrtPiPreset>(
|
||||
crtpi_file_path,
|
||||
crtpi_presets,
|
||||
g_crtpi_file_path,
|
||||
g_crtpi_presets,
|
||||
&writeCrtPiPreset,
|
||||
"Presets CRT-Pi per a AEE 2026. Editable a mà.",
|
||||
"Els noms han de coincidir amb config.yaml > video.shader.current_crtpi_preset.");
|
||||
}
|
||||
|
||||
auto buildActiveShader() -> Rendering::ShaderType {
|
||||
if (video.shader.current_shader == "crtpi") {
|
||||
if (g_video.shader.current_shader == "crtpi") {
|
||||
return Rendering::ShaderType::CRTPI;
|
||||
}
|
||||
return Rendering::ShaderType::POSTFX;
|
||||
}
|
||||
|
||||
auto buildRenderInfoFrame(Uint8 text_color, Uint8 shadow_color) -> RenderInfo::Frame {
|
||||
const std::string& active_preset = (video.shader.current_shader == "crtpi")
|
||||
? video.shader.current_crtpi_preset
|
||||
: video.shader.current_postfx_preset;
|
||||
const std::string& active_preset = (g_video.shader.current_shader == "crtpi")
|
||||
? g_video.shader.current_crtpi_preset
|
||||
: g_video.shader.current_postfx_preset;
|
||||
return {
|
||||
.target_position = video.render_info_position,
|
||||
.show_time = video.render_info_show_time,
|
||||
.shader_enabled = video.shader.enabled,
|
||||
.shader_name = video.shader.current_shader,
|
||||
.target_position = g_video.render_info_position,
|
||||
.show_time = g_video.render_info_show_time,
|
||||
.shader_enabled = g_video.shader.enabled,
|
||||
.shader_name = g_video.shader.current_shader,
|
||||
.shader_preset = active_preset,
|
||||
.text_color = text_color,
|
||||
.shadow_color = shadow_color,
|
||||
@@ -603,12 +669,12 @@ namespace Options {
|
||||
auto buildCrtPiParams() -> Rendering::CrtPiParams {
|
||||
// Si el shader està deshabilitat, retornem defaults (el dispatch anirà per
|
||||
// PostFX passthrough, però sent nets per si algú consulta aquests params).
|
||||
if (!video.shader.enabled) { return {}; }
|
||||
if (!g_video.shader.enabled) { return {}; }
|
||||
|
||||
const auto IT = std::ranges::find_if(crtpi_presets,
|
||||
[](const CrtPiPreset& preset) { return preset.name == video.shader.current_crtpi_preset; });
|
||||
if (IT == crtpi_presets.end()) {
|
||||
std::cout << "Options: preset CrtPi \"" << video.shader.current_crtpi_preset << "\" no trobat; aplicant defaults.\n";
|
||||
const auto IT = std::ranges::find_if(g_crtpi_presets,
|
||||
[](const CrtPiPreset& preset) { return preset.name == g_video.shader.current_crtpi_preset; });
|
||||
if (IT == g_crtpi_presets.end()) {
|
||||
std::cout << "Options: preset CrtPi \"" << g_video.shader.current_crtpi_preset << "\" no trobat; aplicant defaults.\n";
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
@@ -631,12 +697,12 @@ namespace Options {
|
||||
|
||||
auto buildPostFXParams() -> Rendering::PostFXParams {
|
||||
// Si el shader està deshabilitat, retornem params a zero → passthrough.
|
||||
if (!video.shader.enabled) { return {}; }
|
||||
if (!g_video.shader.enabled) { return {}; }
|
||||
|
||||
const auto IT = std::ranges::find_if(postfx_presets,
|
||||
[](const PostFXPreset& preset) { return preset.name == video.shader.current_postfx_preset; });
|
||||
if (IT == postfx_presets.end()) {
|
||||
std::cout << "Options: preset PostFX \"" << video.shader.current_postfx_preset << "\" no trobat; aplicant passthrough.\n";
|
||||
const auto IT = std::ranges::find_if(g_postfx_presets,
|
||||
[](const PostFXPreset& preset) { return preset.name == g_video.shader.current_postfx_preset; });
|
||||
if (IT == g_postfx_presets.end()) {
|
||||
std::cout << "Options: preset PostFX \"" << g_video.shader.current_postfx_preset << "\" no trobat; aplicant passthrough.\n";
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
|
||||
Reference in new issue
Block a user