init
This commit is contained in:
commit
8e6ad52ee4
44 files changed
+6842
No files matched your search
@@ -0,0 +1,73 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using Cthangover.Core.Factories.Impls;
|
||||
using Cthangover.Core.Mods;
|
||||
using Cthangover.Core.Mods.Resolvers;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.Tools.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Static service for discovering and loading mod background resources.
|
||||
/// Queries <see cref="Files.Collect"/> for files under
|
||||
/// <c>"backgrounds"</c>, strips extensions to produce IDs, and loads textures
|
||||
/// via <see cref="BackgroundFactory"/>. Used by the light editor, interactive
|
||||
/// editor, and scenario editor to populate background selection lists and
|
||||
/// render previews.
|
||||
/// </summary>
|
||||
public static class ModResourceService
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns all unique background IDs (filenames without extension) from
|
||||
/// the <c>"backgrounds"</c> file collection across all installed mods.
|
||||
/// </summary>
|
||||
public static List<string> GetBackgroundIds()
|
||||
{
|
||||
var ids = new List<string>();
|
||||
var files = Files.Collect("backgrounds");
|
||||
var exts = GetExt();
|
||||
|
||||
foreach (var kvp in files)
|
||||
{
|
||||
var id = kvp.Key;
|
||||
var ext = Path.GetExtension(id);
|
||||
if (!string.IsNullOrWhiteSpace(id) && exts.Contains(ext.ToLower()))
|
||||
{
|
||||
var cleanId = id.Substring(0, id.LastIndexOf('.'));
|
||||
if (!cleanId.EndsWith("_albedo") && !cleanId.EndsWith("_depth"))
|
||||
ids.Add(cleanId);
|
||||
}
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
private static HashSet<string> GetExt()
|
||||
{
|
||||
var set = new HashSet<string>();
|
||||
foreach (var ext in ModConfig.Instance.TextureExtensions)
|
||||
{
|
||||
if(string.IsNullOrWhiteSpace(ext))
|
||||
continue;
|
||||
set.Add(ext.ToLower());
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads a background texture by ID via <see cref="BackgroundFactory.Instance.Get"/>.
|
||||
/// Returns <c>null</c> on any error (missing resource, load failure).
|
||||
/// </summary>
|
||||
public static Texture2D LoadBackgroundTexture(string id)
|
||||
{
|
||||
try
|
||||
{
|
||||
return BackgroundFactory.Instance.Get(id);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Cthangover.Core.Scenarios;
|
||||
using Cthangover.Core.Factories.Impls;
|
||||
using Cthangover.Core.Utils;
|
||||
using Cthangover.Core.Mods;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.Tools.Services
|
||||
{
|
||||
/// <summary>Parsed metadata from a scenario file header (before the <c>---</c> separator).</summary>
|
||||
public sealed class ScenarioMetadata
|
||||
{
|
||||
/// <summary>Target scene name for the scenario.</summary>
|
||||
public string Scene { get; set; } = "-";
|
||||
/// <summary>Priority level of the scenario.</summary>
|
||||
public string Priority { get; set; } = "-";
|
||||
/// <summary>Condition string for the scenario to activate.</summary>
|
||||
public string Condition { get; set; } = "-";
|
||||
}
|
||||
|
||||
/// <summary>Collected references extracted from scenario command lines for the info panel.</summary>
|
||||
public sealed class ScenarioReferences
|
||||
{
|
||||
/// <summary>Scene switch targets referenced in commands.</summary>
|
||||
public List<string> SwitchTargets { get; set; } = new();
|
||||
/// <summary>Localisation keys found in named parameters.</summary>
|
||||
public List<string> LocaleKeys { get; set; } = new();
|
||||
/// <summary>Quest IDs referenced in named <c>quest_id</c> parameters.</summary>
|
||||
public List<string> QuestRefs { get; set; } = new();
|
||||
/// <summary>Background IDs referenced in positional parameters of background-related commands.</summary>
|
||||
public List<string> BackgroundRefs { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Describes a single scenario file found during scanning.</summary>
|
||||
public sealed class ScenarioFileInfo
|
||||
{
|
||||
/// <summary>Absolute path on disk.</summary>
|
||||
public string AbsolutePath { get; set; }
|
||||
/// <summary>Filename without extension.</summary>
|
||||
public string Name { get; set; }
|
||||
/// <summary>Directory path relative to the mod's <c>scenarios/</c> folder.</summary>
|
||||
public string RelativeDir { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>A group of scenario files within the same subdirectory under <c>scenarios/</c>.</summary>
|
||||
public sealed class ScenarioGroup
|
||||
{
|
||||
/// <summary>Relative directory path (empty for root-level files).</summary>
|
||||
public string DirectoryPath { get; set; }
|
||||
/// <summary>Files in this group, sorted by name.</summary>
|
||||
public List<ScenarioFileInfo> Files { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Represents all scenario files for one mod, grouped by subdirectory.</summary>
|
||||
public sealed class ModScenarioTree
|
||||
{
|
||||
/// <summary>The mod's identifier.</summary>
|
||||
public string ModId { get; set; }
|
||||
/// <summary>Groups of scenario files within this mod.</summary>
|
||||
public List<ScenarioGroup> Groups { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Static service for discovering, reading, writing, and analysing scenario files.
|
||||
/// Scans all <c>mods/*/scenarios/**/*.scenario</c> files in the project, groups
|
||||
/// them by mod and subdirectory, parses YAML-like metadata blocks, and extracts
|
||||
/// referenced background/scene/locale/quest identifiers. Used by
|
||||
/// <see cref="ScenarioEditorWindow"/> for the file tree and info panel.
|
||||
/// </summary>
|
||||
public static class ScenarioFileService
|
||||
{
|
||||
/// <summary>
|
||||
/// Recursively scans <c>res://mods/*/scenarios/</c> for <c>.scenario</c> files.
|
||||
/// Groups results by mod ID and subdirectory. Returns an empty list if the
|
||||
/// mods directory does not exist.
|
||||
/// </summary>
|
||||
public static List<ModScenarioTree> ScanScenarioFiles()
|
||||
{
|
||||
var result = new List<ModScenarioTree>();
|
||||
var roots = ModRegistry.AllModsRoots;
|
||||
|
||||
foreach (var (modsPath, _) in roots)
|
||||
{
|
||||
if (!Directory.Exists(modsPath))
|
||||
continue;
|
||||
|
||||
foreach (var modDir in Directory.GetDirectories(modsPath).OrderBy(d => Path.GetFileName(d)))
|
||||
{
|
||||
var modId = Path.GetFileName(modDir);
|
||||
var scenariosDir = Path.Combine(modDir, "scenarios");
|
||||
if (!Directory.Exists(scenariosDir))
|
||||
continue;
|
||||
|
||||
var scenarioFiles = Directory.GetFiles(scenariosDir, "*.scenario", SearchOption.AllDirectories);
|
||||
if (scenarioFiles.Length == 0)
|
||||
continue;
|
||||
|
||||
var groups = scenarioFiles
|
||||
.GroupBy(f =>
|
||||
{
|
||||
var relDir = Path.GetRelativePath(scenariosDir, Path.GetDirectoryName(f));
|
||||
return relDir == "." ? "" : relDir.Replace('\\', '/');
|
||||
})
|
||||
.OrderBy(g => g.Key)
|
||||
.Select(g => new ScenarioGroup
|
||||
{
|
||||
DirectoryPath = g.Key,
|
||||
Files = g.OrderBy(f => Path.GetFileNameWithoutExtension(f))
|
||||
.Select(f => new ScenarioFileInfo
|
||||
{
|
||||
AbsolutePath = f,
|
||||
Name = Path.GetFileNameWithoutExtension(f),
|
||||
RelativeDir = g.Key
|
||||
})
|
||||
.ToList()
|
||||
})
|
||||
.ToList();
|
||||
|
||||
result.Add(new ModScenarioTree { ModId = modId, Groups = groups });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the header block (before first <c>---</c> line) in YAML-like
|
||||
/// <c>key: value</c> format to extract scene, priority, and condition.
|
||||
/// </summary>
|
||||
public static ScenarioMetadata ParseMetadata(string text)
|
||||
{
|
||||
var meta = new ScenarioMetadata();
|
||||
var metaEnd = text.IndexOf("\n---");
|
||||
var metaText = metaEnd >= 0 ? text.Substring(0, metaEnd) : "";
|
||||
|
||||
foreach (var line in metaText.Split('\n'))
|
||||
{
|
||||
var t = line.Trim();
|
||||
if (t.Length == 0 || t.StartsWith("#"))
|
||||
continue;
|
||||
|
||||
var ci = t.IndexOf(':');
|
||||
if (ci <= 0)
|
||||
continue;
|
||||
|
||||
var key = t.Substring(0, ci).Trim().ToLowerInvariant();
|
||||
var val = t.Substring(ci + 1).Trim();
|
||||
|
||||
switch (key)
|
||||
{
|
||||
case "scene": meta.Scene = val; break;
|
||||
case "priority": meta.Priority = val; break;
|
||||
case "condition": meta.Condition = val; break;
|
||||
}
|
||||
}
|
||||
|
||||
return meta;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walks the body of the scenario (after <c>---</c>) and extracts references:
|
||||
/// background IDs, scene switch targets, locale keys, and quest IDs.
|
||||
/// Uses <see cref="ScenarioSyntaxService.ExtractCommandParams"/> to tokenise
|
||||
/// each line and <see cref="ScenarioCommandStrategyFactory"/> metadata to
|
||||
/// classify positional parameters.
|
||||
/// </summary>
|
||||
/// <param name="text">Full scenario file text including header.</param>
|
||||
public static ScenarioReferences ExtractReferences(string text)
|
||||
{
|
||||
var refs = new ScenarioReferences();
|
||||
var metaEnd = text.IndexOf("\n---");
|
||||
var content = metaEnd >= 0 ? text.Substring(metaEnd + 4) : text;
|
||||
|
||||
foreach (var rawLine in content.Split('\n'))
|
||||
{
|
||||
var line = rawLine.Trim();
|
||||
if (line.Length == 0 || line.StartsWith("#") || line.StartsWith(":"))
|
||||
continue;
|
||||
|
||||
ScenarioSyntaxService.ExtractCommandParams(line, out var cmd, out var positional, out var named);
|
||||
|
||||
if (string.IsNullOrEmpty(cmd))
|
||||
continue;
|
||||
|
||||
var meta = ScenarioCommandStrategyFactory.GetReferenceMetadata(cmd);
|
||||
if (meta != null && positional.Count > 0)
|
||||
{
|
||||
switch (meta.Positional0Kind)
|
||||
{
|
||||
case PositionalReferenceKind.Background:
|
||||
refs.BackgroundRefs.Add(positional[0]);
|
||||
break;
|
||||
case PositionalReferenceKind.Scene:
|
||||
refs.SwitchTargets.Add(positional[0]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (named.TryGetValue("key", out var lk))
|
||||
refs.LocaleKeys.Add(lk);
|
||||
if (named.TryGetValue("quest_id", out var qid))
|
||||
refs.QuestRefs.Add(qid);
|
||||
}
|
||||
|
||||
return refs;
|
||||
}
|
||||
|
||||
/// <summary>Reads the entire file at <paramref name="filePath"/> as a UTF-8 string.</summary>
|
||||
public static string ReadAllText(string filePath)
|
||||
{
|
||||
return File.ReadAllText(filePath);
|
||||
}
|
||||
|
||||
/// <summary>Writes <paramref name="text"/> to <paramref name="filePath"/>, overwriting existing content.</summary>
|
||||
public static void WriteAllText(string filePath, string text)
|
||||
{
|
||||
File.WriteAllText(filePath, text);
|
||||
}
|
||||
|
||||
/// <summary>Converts an absolute file path to a path relative to <c>res://</c>, using forward slashes.</summary>
|
||||
public static string GetRelativePath(string absolutePath)
|
||||
{
|
||||
var projectPath = GameEnvironment.IsRuntime ? ProjectSettings.GlobalizePath("res://") : "";
|
||||
return Path.GetRelativePath(projectPath, absolutePath).Replace('\\', '/');
|
||||
}
|
||||
|
||||
public static SceneGraphData BuildSceneGraph()
|
||||
{
|
||||
var data = new SceneGraphData();
|
||||
var sceneNodes = new Dictionary<string, SceneGraphNode>(System.StringComparer.OrdinalIgnoreCase);
|
||||
var edgeSet = new HashSet<string>(System.StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var allFiles = ScanAllScenarioFiles();
|
||||
|
||||
foreach (var file in allFiles)
|
||||
{
|
||||
string text;
|
||||
try
|
||||
{
|
||||
text = File.ReadAllText(file.AbsolutePath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var metadata = ParseMetadata(text);
|
||||
if (string.IsNullOrEmpty(metadata.Scene) || metadata.Scene == "-")
|
||||
continue;
|
||||
|
||||
var sceneKey = metadata.Scene.ToLowerInvariant();
|
||||
|
||||
if (!sceneNodes.TryGetValue(sceneKey, out var node))
|
||||
{
|
||||
node = new SceneGraphNode
|
||||
{
|
||||
SceneName = metadata.Scene,
|
||||
ModId = file.ModId,
|
||||
IsStartScene = string.Equals(metadata.Scene, "start_scene", System.StringComparison.OrdinalIgnoreCase)
|
||||
};
|
||||
sceneNodes[sceneKey] = node;
|
||||
}
|
||||
|
||||
node.ScenarioCount++;
|
||||
node.Scenarios.Add(new ScenarioGraphLink
|
||||
{
|
||||
Name = file.Name,
|
||||
Priority = int.TryParse(metadata.Priority, out var p) ? p : 0,
|
||||
FilePath = file.AbsolutePath
|
||||
});
|
||||
|
||||
var refs = ExtractReferences(text);
|
||||
foreach (var target in refs.SwitchTargets)
|
||||
{
|
||||
var srcKey = metadata.Scene.ToLowerInvariant();
|
||||
var dstKey = target.ToLowerInvariant();
|
||||
var edgeKey = srcKey + "|" + dstKey;
|
||||
if (!edgeSet.Contains(edgeKey))
|
||||
{
|
||||
edgeSet.Add(edgeKey);
|
||||
data.Edges.Add(new SceneGraphEdge
|
||||
{
|
||||
FromScene = metadata.Scene,
|
||||
ToScene = target
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var node in sceneNodes.Values)
|
||||
data.Nodes.Add(node);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
private sealed class FlatScenarioFile
|
||||
{
|
||||
public string AbsolutePath;
|
||||
public string Name;
|
||||
public string ModId;
|
||||
}
|
||||
|
||||
private static List<FlatScenarioFile> ScanAllScenarioFiles()
|
||||
{
|
||||
var result = new List<FlatScenarioFile>();
|
||||
var roots = ModRegistry.AllModsRoots;
|
||||
|
||||
foreach (var (modsPath, _) in roots)
|
||||
{
|
||||
if (!Directory.Exists(modsPath))
|
||||
continue;
|
||||
|
||||
foreach (var modDir in Directory.GetDirectories(modsPath))
|
||||
{
|
||||
var modId = Path.GetFileName(modDir);
|
||||
var scenariosDir = Path.Combine(modDir, "scenarios");
|
||||
if (!Directory.Exists(scenariosDir))
|
||||
continue;
|
||||
|
||||
foreach (var file in Directory.GetFiles(scenariosDir, "*.scenario", SearchOption.AllDirectories))
|
||||
{
|
||||
result.Add(new FlatScenarioFile
|
||||
{
|
||||
AbsolutePath = file,
|
||||
Name = Path.GetFileNameWithoutExtension(file),
|
||||
ModId = modId
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Cthangover.Tools.Services
|
||||
{
|
||||
public sealed class ScenarioGraphLink
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public int Priority { get; set; }
|
||||
public string FilePath { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SceneGraphNode
|
||||
{
|
||||
public string SceneName { get; set; }
|
||||
public string ModId { get; set; }
|
||||
public int ScenarioCount { get; set; }
|
||||
public List<string> Backgrounds { get; set; } = new();
|
||||
public bool IsStartScene { get; set; }
|
||||
public List<ScenarioGraphLink> Scenarios { get; set; } = new();
|
||||
public bool HasErrors { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SceneGraphEdge
|
||||
{
|
||||
public string FromScene { get; set; }
|
||||
public string ToScene { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SceneGraphData
|
||||
{
|
||||
public List<SceneGraphNode> Nodes { get; set; } = new();
|
||||
public List<SceneGraphEdge> Edges { get; set; } = new();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
using System.Collections.Generic;
|
||||
using Cthangover.Core.Scenarios;
|
||||
using Cthangover.Core.Factories.Impls;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.Tools.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Static service providing scenario DSL syntax highlighting, tokenisation,
|
||||
/// and command-line parsing. <see cref="CreateHighlighter"/> produces a
|
||||
/// <see cref="CodeHighlighter"/> for the scenario editor's
|
||||
/// <see cref="TextEdit"/>. <see cref="ExtractCommandParams"/> tokenises a
|
||||
/// scenario command line into a command name, positional arguments, and
|
||||
/// named (<c>key=value</c>) parameters — used by
|
||||
/// <see cref="ScenarioFileService.ExtractReferences"/> for reference analysis.
|
||||
/// </summary>
|
||||
public static class ScenarioSyntaxService
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds a <see cref="CodeHighlighter"/> for scenario DSL syntax.
|
||||
/// Comments (lines starting with <c>#</c>) are green, strings are orange,
|
||||
/// command names are blue, and argument keywords (e.g. <c>key</c>,
|
||||
/// <c>wait</c>, <c>quest_id</c>) are amber.
|
||||
/// </summary>
|
||||
public static CodeHighlighter CreateHighlighter()
|
||||
{
|
||||
var hl = new CodeHighlighter();
|
||||
hl.AddColorRegion("#", null, new Color(0.45f, 0.75f, 0.45f), true);
|
||||
hl.AddColorRegion("\"", "\"", new Color(1f, 0.6f, 0.4f), false);
|
||||
hl.NumberColor = new Color(0.7f, 0.7f, 1f);
|
||||
hl.SymbolColor = new Color(0.8f, 0.8f, 0.8f);
|
||||
|
||||
var cmdColor = new Color(0.4f, 0.7f, 1f);
|
||||
hl.KeywordColors = new Godot.Collections.Dictionary();
|
||||
|
||||
foreach (var cmd in ScenarioCommandStrategyFactory.GetAllCommandNames())
|
||||
hl.KeywordColors[cmd] = cmdColor;
|
||||
|
||||
foreach (var cmd in new[] {
|
||||
"option", "select"
|
||||
}) hl.KeywordColors[cmd] = cmdColor;
|
||||
|
||||
var argColor = new Color(0.8f, 0.6f, 0.3f);
|
||||
foreach (var kw in new[] {
|
||||
"key", "first", "second", "hide_color", "wait", "speed", "hidden",
|
||||
"sprites", "loop", "target", "quest_id", "show", "hide", "duration"
|
||||
}) hl.KeywordColors[kw] = argColor;
|
||||
|
||||
return hl;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Splits a line into tokens, handling quoted strings (with backslash
|
||||
/// escaping) and whitespace. Returns all non-whitespace tokens in order.
|
||||
/// </summary>
|
||||
public static List<string> Tokenize(string line)
|
||||
{
|
||||
var result = new List<string>();
|
||||
var i = 0;
|
||||
while (i < line.Length)
|
||||
{
|
||||
if (char.IsWhiteSpace(line[i])) { i++; continue; }
|
||||
if (line[i] == '"')
|
||||
{
|
||||
i++;
|
||||
var start = i;
|
||||
while (i < line.Length && line[i] != '"')
|
||||
{
|
||||
if (line[i] == '\\' && i + 1 < line.Length) i++;
|
||||
i++;
|
||||
}
|
||||
result.Add(line.Substring(start, i - start));
|
||||
if (i < line.Length) i++;
|
||||
continue;
|
||||
}
|
||||
var tok = i;
|
||||
while (i < line.Length && !char.IsWhiteSpace(line[i])) i++;
|
||||
result.Add(line.Substring(tok, i - tok));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Strips surrounding double quotes from a value if present.</summary>
|
||||
public static string StripQuotes(string val)
|
||||
{
|
||||
if (val.Length >= 2 && val.StartsWith("\"") && val.EndsWith("\""))
|
||||
return val.Substring(1, val.Length - 2);
|
||||
return val;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a scenario command line into its components. Tokenises via
|
||||
/// <see cref="Tokenize"/>, then extracts the first token as the command
|
||||
/// name (lowercased), remaining bare tokens as positional arguments, and
|
||||
/// <c>key=value</c> pairs as named arguments. The <c>-></c> arrow token
|
||||
/// is consumed but discarded.
|
||||
/// </summary>
|
||||
/// <param name="line">A single scenario command line (trimmed).</param>
|
||||
/// <param name="cmd">The command name, lowercased.</param>
|
||||
/// <param name="positional">Positional arguments in order.</param>
|
||||
/// <param name="named">Named arguments keyed by lowercased name.</param>
|
||||
public static void ExtractCommandParams(string line, out string cmd, out List<string> positional, out Dictionary<string, string> named)
|
||||
{
|
||||
positional = new List<string>();
|
||||
named = new Dictionary<string, string>(System.StringComparer.OrdinalIgnoreCase);
|
||||
cmd = "";
|
||||
|
||||
var tokens = Tokenize(line);
|
||||
if (tokens.Count == 0)
|
||||
return;
|
||||
|
||||
cmd = tokens[0].ToLowerInvariant();
|
||||
|
||||
for (int t = 1; t < tokens.Count; t++)
|
||||
{
|
||||
var tk = tokens[t];
|
||||
if (tk == "->") { t++; continue; }
|
||||
var eq = tk.IndexOf('=');
|
||||
if (eq > 0)
|
||||
named[tk.Substring(0, eq).ToLowerInvariant()] = StripQuotes(tk.Substring(eq + 1));
|
||||
else
|
||||
positional.Add(tk);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user