Merge remote-tracking branch 'origin/develop' into feature/chat
This commit is contained in:
@@ -7,6 +7,8 @@ namespace BrewMonster.PerfectWorld.Scripts.Utility.ChatFilter
|
||||
{
|
||||
public class ChatFilter
|
||||
{
|
||||
private const string BadWordReplacement = "**";
|
||||
|
||||
private HashSet<string> badWordSet = new HashSet<string>();
|
||||
|
||||
// =========================
|
||||
@@ -26,14 +28,16 @@ namespace BrewMonster.PerfectWorld.Scripts.Utility.ChatFilter
|
||||
// =========================
|
||||
// NORMALIZE
|
||||
// =========================
|
||||
private string NormalizeRuntime(string input, out List<int> map)
|
||||
private string NormalizeRuntime(string input, out List<int> charMap, out List<(int start, int end)> tokenRanges)
|
||||
{
|
||||
map = new List<int>();
|
||||
charMap = new List<int>();
|
||||
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<int> map;
|
||||
string normalized = NormalizeRuntime(input, out map);
|
||||
|
||||
List<int> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 232825bd606418d459919d856f87a06e
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,120 @@
|
||||
using System.IO;
|
||||
using BrewMonster.PerfectWorld.Scripts.Utility.ChatFilter;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BrewMonster.PerfectWorld.Editor.ChatFilter
|
||||
{
|
||||
/// <summary>
|
||||
/// Editor window to test <see cref="ChatFilterService"/> against clean_words.txt.
|
||||
/// </summary>
|
||||
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<ChatFilterTestWindow>("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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs sample inputs that use entries from clean_words.txt (plan: ** replacement, spaces preserved).
|
||||
/// </summary>
|
||||
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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 94224658e5944bf4c88bedffcf97a970
|
||||
Reference in New Issue
Block a user