diff --git a/Assets/PerfectWorld/Scripts/Utility/ChatFilter/ChatFilter.cs b/Assets/PerfectWorld/Scripts/Utility/ChatFilter/ChatFilter.cs index 28d9fe206f..41b4b85318 100644 --- a/Assets/PerfectWorld/Scripts/Utility/ChatFilter/ChatFilter.cs +++ b/Assets/PerfectWorld/Scripts/Utility/ChatFilter/ChatFilter.cs @@ -7,6 +7,8 @@ namespace BrewMonster.PerfectWorld.Scripts.Utility.ChatFilter { public class ChatFilter { + private const string BadWordReplacement = "**"; + private HashSet badWordSet = new HashSet(); // ========================= @@ -26,14 +28,16 @@ namespace BrewMonster.PerfectWorld.Scripts.Utility.ChatFilter // ========================= // NORMALIZE // ========================= - private string NormalizeRuntime(string input, out List map) + private string NormalizeRuntime(string input, out List charMap, out List<(int start, int end)> tokenRanges) { - map = new List(); + charMap = new List(); + tokenRanges = new List<(int, int)>(); string formD = input.Normalize(NormalizationForm.FormD); StringBuilder sb = new StringBuilder(); - bool lastWasSpace = false; + bool inToken = false; + int tokenStart = -1; for (int i = 0; i < formD.Length; i++) { @@ -47,22 +51,32 @@ namespace BrewMonster.PerfectWorld.Scripts.Utility.ChatFilter if (n == '\0') { - if (!lastWasSpace) + if (inToken) { - sb.Append(' '); - map.Add(i); - lastWasSpace = true; + tokenRanges.Add((tokenStart, charMap.Count - 1)); + inToken = false; } + + sb.Append(' '); + charMap.Add(i); } else { + if (!inToken) + { + tokenStart = charMap.Count; + inToken = true; + } + sb.Append(n); - map.Add(i); - lastWasSpace = false; + charMap.Add(i); } } - return sb.ToString().Trim(); + if (inToken) + tokenRanges.Add((tokenStart, charMap.Count - 1)); + + return sb.ToString(); } private char NormalizeChar(char c) @@ -138,21 +152,75 @@ namespace BrewMonster.PerfectWorld.Scripts.Utility.ChatFilter return false; } + private static List<(int start, int end)> MergeOverlappingSpans(List<(int start, int end)> spans) + { + if (spans == null || spans.Count == 0) + return spans; + + spans.Sort((a, b) => a.start.CompareTo(b.start)); + + var merged = new List<(int start, int end)>(spans.Count); + foreach (var span in spans) + { + if (merged.Count == 0) + { + merged.Add(span); + continue; + } + + var last = merged[merged.Count - 1]; + if (span.start <= last.end) + merged[merged.Count - 1] = (last.start, Math.Max(last.end, span.end)); + else + merged.Add(span); + } + + return merged; + } + + private static string BuildFilteredString(string input, List<(int start, int end)> mergedSpans, string replacement) + { + if (mergedSpans == null || mergedSpans.Count == 0) + return input; + + var sb = new StringBuilder(input.Length); + int last = 0; + + foreach (var (s, e) in mergedSpans) + { + if (s > last) + sb.Append(input, last, s - last); + + sb.Append(replacement); + last = e + 1; + } + + if (last < input.Length) + sb.Append(input, last, input.Length - last); + + return sb.ToString(); + } + // ========================= // FILTER // ========================= public string Filter(string input, out bool isValidWord) { isValidWord = false; + if (string.IsNullOrEmpty(input)) return input; + isValidWord = true; - List map; - string normalized = NormalizeRuntime(input, out map); + + List charMap; + List<(int start, int end)> tokenRanges; + + string normalized = NormalizeRuntime(input, out charMap, out tokenRanges); var tokens = normalized.Split(' ', StringSplitOptions.RemoveEmptyEntries); - char[] result = input.ToCharArray(); + var matchSpans = new List<(int start, int end)>(); for (int i = 0; i < tokens.Length; i++) { @@ -161,23 +229,27 @@ namespace BrewMonster.PerfectWorld.Scripts.Utility.ChatFilter int startToken = i; int endToken = i + len - 1; - int startChar = FindCharIndex(normalized, startToken, map); - int endChar = FindCharIndex(normalized, endToken, map); - - if (startChar >= 0 && endChar >= 0) + if (startToken < tokenRanges.Count && endToken < tokenRanges.Count) { - for (int k = startChar; k <= endChar && k < result.Length; k++) - { - result[k] = '*'; - isValidWord = false; - } + int normStart = tokenRanges[startToken].start; + int normEnd = tokenRanges[endToken].end; + + int realStart = charMap[normStart]; + int realEnd = charMap[normEnd]; + + matchSpans.Add((realStart, realEnd)); + isValidWord = false; } i += len - 1; } } - return new string(result); + if (matchSpans.Count == 0) + return input; + + var merged = MergeOverlappingSpans(matchSpans); + return BuildFilteredString(input, merged, BadWordReplacement); } } } diff --git a/Assets/PerfectWorld/Scripts/Utility/ChatFilter/Editor.meta b/Assets/PerfectWorld/Scripts/Utility/ChatFilter/Editor.meta new file mode 100644 index 0000000000..680ab212ef --- /dev/null +++ b/Assets/PerfectWorld/Scripts/Utility/ChatFilter/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 232825bd606418d459919d856f87a06e +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/PerfectWorld/Scripts/Utility/ChatFilter/Editor/ChatFilterTestWindow.cs b/Assets/PerfectWorld/Scripts/Utility/ChatFilter/Editor/ChatFilterTestWindow.cs new file mode 100644 index 0000000000..da5b200b4b --- /dev/null +++ b/Assets/PerfectWorld/Scripts/Utility/ChatFilter/Editor/ChatFilterTestWindow.cs @@ -0,0 +1,120 @@ +using System.IO; +using BrewMonster.PerfectWorld.Scripts.Utility.ChatFilter; +using UnityEditor; +using UnityEngine; + +namespace BrewMonster.PerfectWorld.Editor.ChatFilter +{ + /// + /// Editor window to test against clean_words.txt. + /// + public sealed class ChatFilterTestWindow : EditorWindow + { + private const string CleanWordsRelativePath = "PerfectWorld/Data/clean_words.txt"; + + private string _input = ""; + private string _filtered = ""; + private bool _isValidWord; + private bool _containsBadWord; + private bool _hasRun; + private Vector2 _scroll; + + [MenuItem("Tools/Perfect World/Chat Filter Test")] + public static void Open() + { + GetWindow("Chat Filter Test"); + } + + private void OnGUI() + { + string cleanPath = Path.Combine(Application.dataPath, CleanWordsRelativePath); + if (!File.Exists(cleanPath)) + { + EditorGUILayout.HelpBox( + "Missing badword list file:\n" + cleanPath, + MessageType.Error); + return; + } + + EditorGUILayout.LabelField("Input", EditorStyles.boldLabel); + _input = EditorGUILayout.TextArea(_input, GUILayout.MinHeight(80)); + + EditorGUILayout.Space(6); + using (new EditorGUILayout.HorizontalScope()) + { + if (GUILayout.Button("Run filter", GUILayout.Height(28))) + { + RunTest(); + } + + if (GUILayout.Button("Run plan examples", GUILayout.Height(28))) + { + RunPlanExamplesFromCleanWords(); + } + + if (GUILayout.Button("Clear", GUILayout.Width(72), GUILayout.Height(28))) + { + _input = ""; + _filtered = ""; + _hasRun = false; + } + } + + if (!_hasRun) + return; + + EditorGUILayout.Space(8); + EditorGUILayout.LabelField("Results", EditorStyles.boldLabel); + + _scroll = EditorGUILayout.BeginScrollView(_scroll); + EditorGUILayout.LabelField("Contains bad word", _containsBadWord ? "Yes" : "No"); + EditorGUILayout.LabelField("Is valid word (Filter)", _isValidWord ? "Yes" : "No"); + EditorGUILayout.Space(4); + EditorGUILayout.LabelField("Filtered output"); + EditorGUILayout.SelectableLabel(_filtered, EditorStyles.textField, GUILayout.MinHeight(60)); + EditorGUILayout.EndScrollView(); + } + + private void RunTest() + { + ChatFilterService.Init(); + _containsBadWord = ChatFilterService.ContainsBadWord(_input); + _filtered = ChatFilterService.Filter(_input, out _isValidWord); + _hasRun = true; + Repaint(); + } + + /// + /// Runs sample inputs that use entries from clean_words.txt (plan: ** replacement, spaces preserved). + /// + private static void RunPlanExamplesFromCleanWords() + { + ChatFilterService.Init(); + + var cases = new[] + { + ("con chó", "con **", false), + ("bitch cho", "** **", false), + ("hello world", "hello world", true), + }; + + int failed = 0; + foreach (var (input, expectedFiltered, expectedValid) in cases) + { + string got = ChatFilterService.Filter(input, out bool isValid); + bool ok = got == expectedFiltered && isValid == expectedValid; + if (!ok) + { + failed++; + Debug.LogWarning( + $"[ChatFilter plan check] FAIL\n in: {input}\n expected: {expectedFiltered} (valid={expectedValid})\n got: {got} (valid={isValid})"); + } + else + Debug.Log($"[ChatFilter plan check] OK: \"{input}\" -> \"{got}\""); + } + + if (failed == 0) + Debug.Log("[ChatFilter plan check] All examples passed."); + } + } +} diff --git a/Assets/PerfectWorld/Scripts/Utility/ChatFilter/Editor/ChatFilterTestWindow.cs.meta b/Assets/PerfectWorld/Scripts/Utility/ChatFilter/Editor/ChatFilterTestWindow.cs.meta new file mode 100644 index 0000000000..deef3eeb78 --- /dev/null +++ b/Assets/PerfectWorld/Scripts/Utility/ChatFilter/Editor/ChatFilterTestWindow.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 94224658e5944bf4c88bedffcf97a970 \ No newline at end of file