diff --git a/gradle.properties b/gradle.properties index 4bb39c2..9b3f09f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,7 +6,7 @@ org.gradle.configuration-cache=false minecraft_version=26.2 loader_version=0.19.3 # Mod Properties -mod_version=2.27.3 +mod_version=3.0.0-alpha.8 maven_group=net.legitimoose archives_base_name=Legitimoose-Bot # Dependencies diff --git a/src/client/java/net/legitimoose/bot/LegitimooseBotClient.java b/src/client/java/net/legitimoose/bot/LegitimooseBotClient.java index 72532e6..30ec9a2 100644 --- a/src/client/java/net/legitimoose/bot/LegitimooseBotClient.java +++ b/src/client/java/net/legitimoose/bot/LegitimooseBotClient.java @@ -83,7 +83,7 @@ private static void rejoin() { if ((now - lastJoinTimestamp) >= REJOIN_COOLDOWN_MS) { lastJoinTimestamp = now; LOGGER.info("Attempting to reconnect to server"); - ServerData info = new ServerData("Server", "legitimoose.com", ServerData.Type.OTHER); + ServerData info = new ServerData("Legitimoose", "legitimoose.com", ServerData.Type.OTHER); ConnectScreen.startConnecting( new JoinMultiplayerScreen(null), Minecraft.getInstance(), diff --git a/src/client/java/net/legitimoose/bot/http/endpoint/PlayerEndpoint.java b/src/client/java/net/legitimoose/bot/http/endpoint/PlayerEndpoint.java index af85765..eb85547 100644 --- a/src/client/java/net/legitimoose/bot/http/endpoint/PlayerEndpoint.java +++ b/src/client/java/net/legitimoose/bot/http/endpoint/PlayerEndpoint.java @@ -6,10 +6,12 @@ import net.legitimoose.bot.scraper.Player; import net.legitimoose.bot.scraper.Rank; import net.legitimoose.bot.util.McUtil; +import org.bson.Document; import net.minecraft.network.chat.ClickEvent; import net.minecraft.network.chat.Component; import org.jspecify.annotations.NonNull; +import java.time.Instant; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -25,11 +27,19 @@ public JsonArray handleRequest() { JsonArray response = new JsonArray(); Map usernames = getPlayers(); - for (String user : usernames.keySet()) { + for (String username : usernames.keySet()) { + try { + if (Database.getPlayers().countDocuments(new Document("name", username)) == 0) { + new Player(McUtil.getUuid(username), username, Rank.Unknown, List.of(), new Player.Streak(1, false), Instant.EPOCH, 0).write(); + } + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + for (Player dbPlayer : Database.getPlayers().find()) { JsonObject player = new JsonObject(); try { - String uuid = McUtil.getUuid(user); - Player dbPlayer = Database.getPlayers().find(eq("uuid", uuid)).first(); Rank rank; if (dbPlayer == null) { rank = Rank.Unknown; @@ -39,10 +49,18 @@ public JsonArray handleRequest() { rank = dbPlayer.rank(); } - player.addProperty("uuid", uuid); - player.addProperty("name", user); + boolean online = false; + String world = ""; + if (usernames.get(dbPlayer.name()) != null) { + online = true; + world = usernames.get(dbPlayer.name()); + } + + player.addProperty("uuid", dbPlayer.uuid()); + player.addProperty("name", dbPlayer.name()); player.addProperty("rank", rank.toString()); - player.addProperty("world", usernames.get(user)); + player.addProperty("online", online); + player.addProperty("world", world); response.add(player); } catch (Exception e) { throw new RuntimeException(e); @@ -56,29 +74,36 @@ public JsonObject handleRequest(String uuid) { JsonObject response = new JsonObject(); Map usernames = getPlayers(); - for (String user : usernames.keySet()) { + for (String username : usernames.keySet()) { try { - if (!McUtil.getUuid(user).equals(uuid)) { - continue; - } - Player dbPlayer = Database.getPlayers().find(eq("uuid", uuid)).first(); - Rank rank; - if (dbPlayer == null) { - rank = Rank.Unknown; - } else { - response.addProperty("streak", dbPlayer.streak().days()); - response.addProperty("legiticoins", dbPlayer.legiticoins()); - rank = dbPlayer.rank(); + if (Database.getPlayers().countDocuments(new Document("name", username)) == 0) { + new Player(McUtil.getUuid(username), username, Rank.Unknown, List.of(), new Player.Streak(1, false), Instant.EPOCH, 0).write(); } - - response.addProperty("name", user); - response.addProperty("rank", rank.toString()); - response.addProperty("world", usernames.get(user)); } catch (Exception e) { throw new RuntimeException(e); } } + Player dbPlayer = Database.getPlayers().find(eq("uuid", uuid)).first(); + try { + boolean online = false; + String world = ""; + if (usernames.get(dbPlayer.name()) != null) { + online = true; + world = usernames.get(dbPlayer.name()); + } + + response.addProperty("uuid", dbPlayer.uuid()); + response.addProperty("name", dbPlayer.name()); + response.addProperty("rank", dbPlayer.rank().toString()); + response.addProperty("streak", dbPlayer.streak().days()); + response.addProperty("online", online); + response.addProperty("world", world); + response.addProperty("legiticoins", dbPlayer.legiticoins()); + } catch (Exception e) { + throw new RuntimeException(e); + } + return response; } diff --git a/src/client/java/net/legitimoose/bot/scraper/Database.java b/src/client/java/net/legitimoose/bot/scraper/Database.java index 96cc5c2..cf62f8c 100644 --- a/src/client/java/net/legitimoose/bot/scraper/Database.java +++ b/src/client/java/net/legitimoose/bot/scraper/Database.java @@ -2,7 +2,10 @@ import static net.legitimoose.bot.LegitimooseBot.CONFIG; -import com.mongodb.client.*; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.MongoDatabase; import net.legitimoose.bot.discord.command.mute.BotMute; import org.bson.Document; @@ -61,5 +64,4 @@ public static MongoCollection getBans() { public static MongoCollection getBotMutes() { return getInstance().mutes; } - } \ No newline at end of file diff --git a/src/client/java/net/legitimoose/bot/scraper/Player.java b/src/client/java/net/legitimoose/bot/scraper/Player.java index 13928b2..0296eeb 100644 --- a/src/client/java/net/legitimoose/bot/scraper/Player.java +++ b/src/client/java/net/legitimoose/bot/scraper/Player.java @@ -30,7 +30,7 @@ public void write() { Updates.set("uuid", this.uuid), Updates.set("name", this.name), Updates.set("rank", this.rank), - Updates.set("blocked", this.blocked), + Updates.setOnInsert("blocked", this.blocked), Updates.set("streak", this.streak), Updates.set("last_joined", new BsonDateTime(this.last_joined.toEpochMilli())), Updates.set("legiticoins", this.legiticoins)); diff --git a/src/client/java/net/legitimoose/bot/scraper/Scraper.java b/src/client/java/net/legitimoose/bot/scraper/Scraper.java index 73fb5d7..c225771 100644 --- a/src/client/java/net/legitimoose/bot/scraper/Scraper.java +++ b/src/client/java/net/legitimoose/bot/scraper/Scraper.java @@ -10,6 +10,7 @@ import net.legitimoose.bot.LegitimooseBotClient; import net.legitimoose.bot.util.DiscordUtil; import net.legitimoose.bot.util.DiscordWebhook; +import net.legitimoose.bot.util.Unicode; import net.minecraft.client.Minecraft; import net.minecraft.client.multiplayer.PlayerInfo; import net.minecraft.core.component.DataComponents; @@ -27,6 +28,7 @@ import java.io.IOException; import java.net.URISyntaxException; +import java.time.Instant; import java.util.ArrayList; import java.util.Collection; import java.util.List; @@ -45,7 +47,7 @@ public class Scraper { private static Scraper INSTANCE; private final DiscordWebhook errorWebhook = new DiscordWebhook(CONFIG.errorWebhook); private final Pattern jamScorePattern = Pattern.compile("^CategoryScore\\(rank=(.*), score=(.*)\\)"); - private final Pattern ownerNamePattern = Pattern.compile("^by (?:[^|]+\\|\\s*)?(.+)"); + private final Pattern ownerNamePattern = Pattern.compile("^by (?:([^|]+) \\|\\s*)?(.+)"); private boolean isScraping; private volatile boolean scrapeOverride = false; @@ -101,9 +103,10 @@ public void scrape() throws Exception { Minecraft client = Minecraft.getInstance(); MongoCollection stats = Database.getStats(); stats.createIndex(Indexes.descending("timestamp")); + + Database.getWorlds().dropIndex(new IndexModel(Indexes.ascending("last_scraped_ms"), new IndexOptions().expireAfter(24L, TimeUnit.HOURS)).getKeys()); List indexes = new ArrayList<>(); indexes.add(new IndexModel(Indexes.ascending("world_uuid"))); - indexes.add(new IndexModel(Indexes.ascending("last_scraped_ms"), new IndexOptions().expireAfter(24L, TimeUnit.HOURS))); Database.getWorlds().createIndexes(indexes); // Please ignore the nulls. Only the 'input' is actually used @@ -149,6 +152,56 @@ public void scrape() throws Exception { client.player.closeContainer(); + String lobbyRawDescription = "["; + + World lobby = new World( + "Nov 28, 2023, 4:57 PM", + 1701219420, + + false, + true, + + "5f4641cb-a718-4556-8c87-fbf153a8cc9a", + "Legitermoose", + Rank.Moose, + + Minecraft.getInstance().getConnection().getOnlinePlayers().size(), + + 100, + 100, + + "", + "lobby", + + // Change on moose/bot update + "1.21.10", + + 300000, + 300000, + + false, + + "Legitimoose Lobby", + Unicode.normalize("Legitimoose Lobby"), + Minecraft.getInstance().getCurrentServer().motd.getString(), + + "{\"text\":\"Legitimoose Lobby\",\"color\":\"white\",\"italic\":false}", + lobbyRawDescription + ComponentSerialization.CODEC.encodeStart(JsonOps.INSTANCE, Minecraft.getInstance().getCurrentServer().motd) + .result() + .get() + "]", + + -1, + + new org.bson.json.JsonObject("{}"), + + "minecraft:grass_block", + + System.currentTimeMillis() / 1000L, + System.currentTimeMillis(), + false + ); + bulkUpsert(List.of(lobby), List.of()); + client.player.connection.sendCommand("browse"); waitSeconds(1); @@ -157,6 +210,7 @@ public void scrape() throws Exception { ItemStack firstItem = null; while (!lastPage) { List worlds = new ArrayList<>(); + List players = new ArrayList<>(); Container inv = client.player.containerMenu.getSlot(0).container; for (int j = 0; j <= 26; j++) { @@ -185,15 +239,49 @@ public void scrape() throws Exception { } String owner_name = ""; + Rank owner_rank = Rank.Unknown; int ownerLine = descriptionLines; while (!itemStack.get(DataComponents.LORE).lines().get(ownerLine).getString().startsWith("by")) { ownerLine++; } Matcher ownerNameMatcher = ownerNamePattern.matcher(itemStack.get(DataComponents.LORE).lines().get(ownerLine).getString()); if (ownerNameMatcher.find()) { - owner_name = ownerNameMatcher.group(1); + owner_name = ownerNameMatcher.group(2); + if (ownerNameMatcher.group(1) == null) { + owner_rank = Rank.Non; + } else { + owner_rank = Rank.getEnum(ownerNameMatcher.group(1)); + } } + String owner_uuid = getNbtString(legitimooseData, "owner").get(); + Player dbPlayer = Database.getPlayers().find(eq("uuid", owner_uuid)).first(); + int streak; + int legiticoins; + Instant last_joined; + if (dbPlayer == null) { + last_joined = Instant.EPOCH; + streak = 0; + legiticoins = 0; + } else { + if (dbPlayer.last_joined() != null) { + last_joined = dbPlayer.last_joined(); + } else { + last_joined = Instant.EPOCH; + } + if (dbPlayer.streak() == null) { + streak = 0; + } else { + streak = dbPlayer.streak().days(); + } + if (dbPlayer.legiticoins() == null) { + legiticoins = 0; + } else { + legiticoins = dbPlayer.legiticoins(); + } + } + players.add(new Player(owner_uuid, owner_name, owner_rank, List.of(), new Player.Streak(streak, false), last_joined, legiticoins)); + StringBuilder description = new StringBuilder(); for (int k = 0; k < descriptionLines; k++) { description.append(itemStack.get(DataComponents.LORE).lines().get(k).getString()); @@ -239,6 +327,8 @@ public void scrape() throws Exception { } } + String itemName = itemStack.get(DataComponents.CUSTOM_NAME).getString(); + World world = new World( getNbtString(legitimooseData, "creation_date").get(), getNbtInt(legitimooseData, "creation_date_unix_seconds"), @@ -246,8 +336,9 @@ public void scrape() throws Exception { getNbtBoolean(legitimooseData, "enforce_whitelist"), getNbtBoolean(legitimooseData, "locked"), - getNbtString(legitimooseData, "owner").get(), + owner_uuid, owner_name, + owner_rank, getNbtInt(legitimooseData, "player_count"), getNbtInt(legitimooseData, "max_players"), @@ -262,7 +353,8 @@ public void scrape() throws Exception { getNbtBoolean(legitimooseData, "whitelist_on_version_change"), - itemStack.get(DataComponents.CUSTOM_NAME).getString(), + itemName, + Unicode.normalize(itemName), description.toString(), ComponentSerialization.CODEC.encodeStart(JsonOps.INSTANCE, itemStack.get(DataComponents.CUSTOM_NAME)) @@ -273,19 +365,18 @@ public void scrape() throws Exception { featured_instant, - jam_world, - jam_id, - - jam, + new org.bson.json.JsonObject(jam.toString()), itemStack.get(DataComponents.ITEM_MODEL).toString(), System.currentTimeMillis() / 1000L, - System.currentTimeMillis() + System.currentTimeMillis(), + false ); + worlds.add(world); LOGGER.info("Scraped World {} {}: {}", j, world.world_uuid(), world.name()); } - bulkUpsert(worlds); + bulkUpsert(worlds, players); // finally, click on next page button LOGGER.info("Scraped page"); Minecraft.getInstance() @@ -299,16 +390,23 @@ public void scrape() throws Exception { LOGGER.info("Finished Scraping"); } - private void bulkUpsert(List worlds) { + private void bulkUpsert(List worlds, List players) { List> operations = new ArrayList<>(); + List> playerOperations = new ArrayList<>(); LOGGER.info("writing world"); for (World world : worlds) { - Document prevWorld = Database.getWorldStats().find(eq("world_uuid", world.world_uuid())).first(); + boolean deleted = false; + World worldPrev = Database.getWorlds().find(eq("world_uuid", world.world_uuid())).first(); + if (worldPrev != null && System.currentTimeMillis() - worldPrev.last_scraped_ms() > TimeUnit.HOURS.toMillis(24)) { + deleted = true; + } + + Document prevWorldStats = Database.getWorldStats().find(eq("world_uuid", world.world_uuid())).first(); JsonObject statsObj = new JsonObject(); - if (prevWorld != null) { - Document prevWorldStats = prevWorld.getList("stats", Document.class).getLast(); - if (world.visits() != prevWorldStats.getInteger("visits") - || world.votes() != prevWorldStats.getInteger("votes")) { + if (prevWorldStats != null) { + Document stats = prevWorldStats.getList("stats", Document.class).getLast(); + if (world.visits() != stats.getInteger("visits") + || world.votes() != stats.getInteger("votes")) { writeWorldStats(world, statsObj); } } else { @@ -323,6 +421,7 @@ private void bulkUpsert(List worlds) { Updates.set("locked", world.locked()), Updates.set("owner_uuid", world.owner_uuid()), Updates.set("owner_name", world.owner_name()), + Updates.set("owner_rank", world.owner_rank()), Updates.set("player_count", world.player_count()), Updates.set("max_players", world.max_players()), Updates.set("max_datapack_size", world.max_datapack_size()), @@ -332,16 +431,16 @@ private void bulkUpsert(List worlds) { Updates.set("votes", world.votes()), Updates.set("whitelist_on_version_change", world.whitelist_on_version_change()), Updates.set("name", world.name()), + Updates.set("normalized_name", world.normalized_name()), Updates.set("description", world.description()), Updates.set("raw_name", Document.parse(world.raw_name())), Updates.set("raw_description", BsonArray.parse(world.raw_description())), Updates.set("featured_instant", world.featured_instant()), - Updates.set("jam_world", world.jam_world()), - Updates.set("jam_id", world.jam_id()), - Updates.set("jam", Document.parse(world.jam().toString())), + Updates.set("jam", world.jam()), Updates.set("icon", world.icon()), Updates.set("last_scraped", world.last_scraped()), - Updates.set("last_scraped_ms", new BsonDateTime(world.last_scraped_ms()))); + Updates.set("last_scraped_ms", new BsonDateTime(world.last_scraped_ms())), + Updates.set("deleted", deleted)); operations.add(new UpdateOneModel<>( eq("world_uuid", world.world_uuid()), updates, @@ -349,10 +448,33 @@ private void bulkUpsert(List worlds) { )); } + for (Player player : players) { + Bson playerUpdates = + Updates.combine( + Updates.set("uuid", player.uuid()), + Updates.set("name", player.name()), + Updates.set("rank", player.rank()), + Updates.set("streak", player.streak()), + Updates.set("last_joined", new BsonDateTime(player.last_joined().toEpochMilli())), + Updates.set("legiticoins", player.legiticoins()), + Updates.setOnInsert("blocked", player.blocked())); + + playerOperations.add(new UpdateOneModel<>( + eq("uuid", player.uuid()), + playerUpdates, + new UpdateOptions().upsert(true) + )); + } + if (!operations.isEmpty()) { Database.getWorlds().bulkWrite(operations); } + + if (!playerOperations.isEmpty()) { + Database.getPlayers().bulkWrite(playerOperations); + } LOGGER.info("Bulk wrote {} worlds", operations.size()); + LOGGER.info("Bulk wrote {} players", playerOperations.size()); } private void writeWorldStats(World world, JsonObject statsObj) { diff --git a/src/client/java/net/legitimoose/bot/scraper/World.java b/src/client/java/net/legitimoose/bot/scraper/World.java index c34e2b4..1cad3c3 100644 --- a/src/client/java/net/legitimoose/bot/scraper/World.java +++ b/src/client/java/net/legitimoose/bot/scraper/World.java @@ -1,7 +1,7 @@ package net.legitimoose.bot.scraper; -import com.google.gson.JsonObject; -import org.jetbrains.annotations.ApiStatus; +import org.bson.codecs.pojo.annotations.BsonProperty; +import org.bson.json.JsonObject; public record World( String creation_date, @@ -10,6 +10,7 @@ public record World( boolean locked, String owner_uuid, String owner_name, + Rank owner_rank, int player_count, int max_players, int max_datapack_size, @@ -20,19 +21,12 @@ public record World( int votes, boolean whitelist_on_version_change, String name, + String normalized_name, String description, String raw_name, String raw_description, int featured_instant, - @Deprecated - @ApiStatus.ScheduledForRemoval(inVersion = "3.0.0") // API v4 - boolean jam_world, - - @Deprecated - @ApiStatus.ScheduledForRemoval(inVersion = "3.0.0") // API v4 - int jam_id, - JsonObject jam, String icon, @@ -40,6 +34,9 @@ public record World( @Deprecated long last_scraped, - long last_scraped_ms + long last_scraped_ms, + + @BsonProperty("legitidevs.deleted") + boolean deleted ) { } diff --git a/src/client/java/net/legitimoose/bot/util/Unicode.java b/src/client/java/net/legitimoose/bot/util/Unicode.java new file mode 100644 index 0000000..38eb3c6 --- /dev/null +++ b/src/client/java/net/legitimoose/bot/util/Unicode.java @@ -0,0 +1,104 @@ +package net.legitimoose.bot.util; + +import java.util.Map; + +public class Unicode { + private static final Map CONFUSABLES = Map.ofEntries( + Map.entry(" ", " "), + Map.entry("0", "⓿"), + Map.entry("1", "11⓵➊⑴¹𝟏𝟙1𝟷𝟣⒈𝟭1➀₁①❶⥠"), + Map.entry("2", "⓶⒉⑵➋ƻ²ᒿ𝟚2𝟮𝟤ᒾ𝟸Ƨ𝟐②ᴤ₂➁❷ᘝƨ"), + Map.entry("3", "³ȝჳⳌꞫ𝟑ℨ𝟛𝟯𝟥Ꝫ➌ЗȜ⓷ӠƷ3𝟹⑶⒊ʒʓǯǮƺ𝕴ᶾзᦡ➂③₃ᶚᴣᴟ❸ҘҙӬӡӭӟӞ"), + Map.entry("4", "𝟰𝟺𝟦𝟒➍ҶᏎ𝟜ҷ⓸ҸҹӴӵᶣ4чㄩ⁴➃₄④❹Ӌ⑷⒋"), + Map.entry("5", "𝟱⓹➎Ƽ𝟓𝟻𝟝𝟧5➄₅⑤⁵❺ƽ⑸⒌"), + Map.entry("6", "Ⳓ🄇𝟼Ꮾ𝟲𝟞𝟨𝟔➏⓺Ϭϭ⁶б6ᧈ⑥➅₆❻⑹⒍"), + Map.entry("7", "𝟕𝟟𝟩𝟳𝟽🄈⓻𐓒➐7⁷⑦₇❼➆⑺⒎"), + Map.entry("8", "𐌚🄉➑⓼8𝟠𝟪৪⁸₈𝟴➇⑧❽𝟾𝟖⑻⒏"), + Map.entry("9", "൭Ꝯ𝝑𝞋𝟅🄊𝟡𝟵Ⳋ⓽➒੧৭୨9𝟫𝟿𝟗⁹₉Գ➈⑨❾⑼⒐"), + Map.entry("10", "⓾❿➉➓🔟⑩⑽⒑"), + Map.entry("11", "⑪⑾⒒⓫"), + Map.entry("12", "⑫⑿⒓⓬"), + Map.entry("13", "⑬⒀⒔⓭"), + Map.entry("14", "⑭⒁⒕⓮"), + Map.entry("15", "⑮⒂⒖⓯"), + Map.entry("16", "⑯⒃⒗⓰"), + Map.entry("17", "⑰⒄⒘⓱"), + Map.entry("18", "⑱⒅⒙⓲"), + Map.entry("19", "⑲⒆⒚⓳"), + Map.entry("20", "⑳⒇⒛⓴"), + Map.entry("ae", "æ"), + Map.entry("OE", "Œ"), + Map.entry("oe", "œ"), + Map.entry("pi", "ᒆ"), + Map.entry("Nj", "Nj"), + Map.entry("AE", "ᴁ"), + Map.entry("A", "𝑨𝔄ᗄ𝖠𝗔ꓯ𝞐🄐🄰Ꭿ𐊠𝕬𝜜𝐴ꓮᎪ𝚨ꭺ𝝖🅐Å∀🇦₳🅰𝒜𝘈𝐀𝔸дǺᗅⒶAΑᾋᗩĂÃÅǍȀȂĀȺĄʌΛλƛᴀᴬДАልÄₐᕱªǞӒΆẠẢẦẨẬẮẰẲẴẶᾸᾹᾺΆᾼᾈᾉᾊᾌᾍᾎᾏἈἉἊἋἌἍἎἏḀȦǠӐÀÁÂẤẪ𝛢𝓐𝙰𝘼ᗩ"), + Map.entry("a", "∂⍺ⓐձǟᵃᶏ⒜аɒaαȃȁคǎმäɑāɐąᾄẚạảǡầẵḁȧӑӓãåάὰάăẩằẳặᾀᾁᾂᾃᾅᾆᾰᾱᾲᾳᾴᶐᾶᾷἀἁἂἃἄἅἆἇᾇậắàáâấẫǻⱥ𝐚𝑎𝒂𝒶𝓪𝔞𝕒𝖆𝖺𝗮𝘢𝙖𝚊𝛂𝛼𝜶𝝰𝞪⍶"), + Map.entry("B", "🄑𝔙𝖁ꞵ𝛃𝛽𝜷𝝱𝞫Ᏸ𐌁𝑩𝕭🄱𐊡𝖡𝘽ꓐ𝗕𝘉𝜝𐊂𝚩𝐁𝛣𝝗𝐵𝙱𝔹Ᏼᏼ𝞑Ꞵ𝔅🅑฿𝓑ᗿᗾᗽ🅱ⒷBвϐᗷƁ乃ßცჩ๖βɮБՅ๒ᙖʙᴮᵇጌḄℬΒВẞḂḆɃദᗹᗸᵝᙞᙟᙝᛒᙗᙘᴃ🇧"), + Map.entry("b", "ꮟᏏ𝐛𝘣𝒷𝔟𝓫𝖇𝖻𝑏𝙗𝕓𝒃𝗯𝚋♭ᑳᒈbᖚᕹᕺⓑḃḅҍъḇƃɓƅᖯƄЬᑲþƂ⒝ЪᶀᑿᒀᒂᒁᑾьƀҌѢѣᔎ"), + Map.entry("C", "ꞆႠ℃🄒ᏟⲤ🄲ꓚ𐊢𐌂🅲𐐕🅒☾ČÇⒸCↃƇᑕㄈ¢८↻ĈϾՇȻᙅᶜ⒞ĆҀĊ©टƆℂℭϹС匚ḈҪʗᑖᑡᑢᑣᑤᑥⅭ𝐂𝐶𝑪𝒞𝓒𝕮𝖢𝗖𝘊𝘾ᔍ"), + Map.entry("c", "🝌cⅽ𝐜𝑐𝒄𝒸𝓬𝔠𝕔𝖈𝖼𝗰𝘤𝙘𝚌ᴄϲⲥсꮯ𐐽ⲥ𐐽ꮯĉcⓒćčċçҁƈḉȼↄсርᴄϲҫ꒝ςɽϛ𝙲ᑦ᧚𝐜𝑐𝒄𝒸𝓬𝔠𝕔𝖈𝖼𝗰𝘤𝙘𝚌₵🇨ᥴᒼⅽ"), + Map.entry("D", "🄓Ꭰ🄳𝔡𝖉𝔻𝗗𝘋𝙳𝐷𝓓𝐃𝑫𝕯𝖣𝔇𝘿ꭰⅅ𝒟ꓓ🅳🅓ⒹDƉᗪƊÐԺᴅᴰↁḊĐÞⅮᗞᑯĎḌḐḒḎᗫᗬᗟᗠᶛᴆ🇩"), + Map.entry("d", "Ꮷ𝔡𝖉ᑯꓒ𝓭ᵭ₫ԃⓓdḋďḍḑḓḏđƌɖɗᵈ⒟ԁⅾᶁԀᑺᑻᑼᑽᒄᑰᑱᶑ𝕕𝖽𝑑𝘥𝒅𝙙𝐝𝗱𝚍ⅆ𝒹ʠժ"), + Map.entry("E", "£ᙓ⋿∃ⴺꓱ𝐄𝐸𝔈𝕰𝖤𝘌𝙴𝛦𝜠ꭼ🄔🄴𝙀𝔼𐊆𝚬ꓰ𝝚𝞔𝓔𝑬𝗘🅴🅔ⒺΈEƎἝᕮƐモЄᴇᴱᵉÉ乇ЁɆꂅ€ÈℰΕЕⴹᎬĒĔĖĘĚÊËԐỀẾỄỂẼḔḖẺȄȆẸỆȨḜḘḚἘἙἚἛἜῈΈӖὲέЀϵ🇪"), + Map.entry("e", "əәⅇꬲꞓ⋴𝛆𝛜𝜀𝜖𝜺𝝐𝝴𝞊𝞮𝟄ⲉꮛ𐐩ꞒⲈ⍷𝑒𝓮𝕖𝖊𝘦𝗲𝚎𝙚𝒆𝔢𝖾𝐞Ҿҿⓔe⒠èᧉéᶒêɘἔềếễ૯ǝєεēҽɛểẽḕḗĕėëẻěȅȇẹệȩɇₑęḝḙḛ℮еԑѐӗᥱёἐἑἒἓἕℯ"), + Map.entry("F", "ᖵꘘꓞꟻᖷ𝐅𝐹𝑭𝔽𝕱𝖥𝗙𝙁𝙵𝟊℉🄕🄵𐊇𝔉𝘍𐊥ꓝꞘ🅵🅕𝓕ⒻFғҒᖴƑԲϝቻḞℱϜ₣🇫Ⅎ"), + Map.entry("f", "𝐟ᵮ𝑓𝒇𝒻𝓯𝔣𝕗𝖿𝗳𝙛𝚏ꬵꞙẝ𝖋ⓕfƒḟʃբᶠ⒡ſꊰʄ∱ᶂ𝘧"), + Map.entry("G", "𝗚𝘎🄖ꓖᏳ🄶Ꮐᏻ𝔾𝓖𝑮𝕲ꮐ𝒢𝙂𝖦𝙶𝔊𝐺𝐆🅶🅖ⒼGɢƓʛĢᘜᴳǴĠԌĜḠĞǦǤԍ₲🇬⅁"), + Map.entry("g", "ᶃᶢⓖgǵĝḡğġǧģց૭ǥɠﻭﻮᵍ⒢ℊɡᧁ𝐠𝑔𝒈𝓰𝔤𝕘𝖌𝗀𝗴𝘨𝙜𝚐"), + Map.entry("H", "Ἤ🄗𝆦🄷𝜢ꓧ𝘏𝐻𝝜𝖧𐋏𝗛ꮋℍᎻℌⲎ𝑯𝞖🅷🅗ዞǶԋⒽHĤᚺḢḦȞḤḨḪĦⱧҢңҤῊΉῌἨἩἪἫἭἮἯᾘᾙᾚᾛᾜᾝᾞᾟӉӈҥΉн卄♓𝓗ℋН𝐇𝙃𝙷ʜ𝛨Η𝚮ᕼӇᴴᵸ🇭"), + Map.entry("h", "ꞕ৸𝕳ꚕᏲℏӊԊꜧᏂҺ⒣ђⓗhĥḣḧȟḥḩḫẖħⱨհһከኩኪካɦℎ𝐡𝒉𝒽𝓱𝔥𝕙𝖍𝗁𝗵𝘩𝙝𝚑իʰᑋᗁɧんɥ"), + Map.entry("I", "ⲒἿ🄘🄸ЇꀤᏆ🅸🅘إﺇٳأﺃٲٵⒾI៸ÌÍÎĨĪĬİÏḮỈǏȈȊỊĮḬƗェエῘῙῚΊἸἹἺἻἼἽἾⅠΪΊɪᶦᑊᥣ𝛪𝐈𝙄𝙸𝓵𝐼ᴵ𝚰𝑰🇮"), + Map.entry("i", "⍳ℹⅈ𝑖𝒊𝒾ı𝚤ɩιιͺ𝛊𝜄𝜾𝞲ꙇӏꭵᎥⓘiìíîĩīĭïḯỉǐȉȋịḭῐῑῒΐῖῗἰἱἲⅰⅼ∣ⵏ│׀ا١۱ߊᛁἳἴἵɨіὶίᶖ𝔦𝚒𝝸𝗂𝐢𝕚𝖎𝗶𝘪𝙞ίⁱᵢ𝓲⒤"), + Map.entry("J", "𝐉𝐽𝑱𝒥𝓙𝔍𝕁𝕵𝖩𝗝𝘑𝙅𝙹ꞲͿꓙ🄙🄹🅹🅙ⒿJЈʝᒍנフĴʆวلյʖᴊᴶﻝጋɈⱼՂๅႱįᎫȷ丿ℐℑᒘᒙᒚᒛᒴᒵᒎᒏ🇯"), + Map.entry("j", "𝚥ꭻⅉⓙjϳʲ⒥ɉĵǰјڶᶨ𝒿𝘫𝗷𝑗𝙟𝔧𝒋𝗃𝓳𝕛𝚓𝖏𝐣"), + Map.entry("K", "𝐊ꝄꝀ𝐾𝑲𝓚𝕶𝖪𝙺𝚱𝝟🄚𝗞🄺𝜥𝘒ꓗ𝙆𝕂Ⲕ𝔎𝛫Ꮶ𝞙𝒦🅺🅚₭ⓀKĸḰќƘкҠκқҟӄʞҚКҡᴋᴷᵏ⒦ᛕЌጕḲΚKҜҝҞĶḴǨⱩϗӃ🇰"), + Map.entry("k", "ⓚꝁkḱǩḳķḵƙⱪᶄ𝐤𝘬𝗄𝕜𝜅𝜘𝜿𝝒𝝹𝞌𝞳𝙠𝚔𝑘𝒌ϰ𝛋𝛞𝟆𝗸𝓴𝓀"), + Map.entry("L", "𝐋𝐿𝔏𝕃𝕷𝖫𝗟𝘓𝙇ﴼ🄛🄻𐐛Ⳑ𝑳𝙻𐑃𝓛ⳑꮮᏞꓡ🅻🅛ﺈ└ⓁւLĿᒪ乚ՆʟꓶιԼᴸˡĹረḶₗΓլĻᄂⅬℒⱢᥧᥨᒻᒶᒷᶫﺎᒺᒹᒸᒫ⎳ㄥŁⱠﺄȽ🇱"), + Map.entry("l", "𝙡ⓛlŀĺľḷḹļӀℓḽḻłレɭƚɫⱡ|Ɩ⒧ʅǀוןΙІ|ᶩӏ𝓘𝕀𝖨𝗜𝘐𝐥𝑙𝒍𝓁𝔩𝕝𝖑𝗅𝗹𝘭𝚕𝜤𝝞ı𝚤ɩι𝛊𝜄𝜾𝞲"), + Map.entry("M", "ꮇ🄜🄼𐌑𐊰ꓟⲘᎷ🅼🅜ⓂMмṂ൱ᗰ州ᘻო๓♏ʍᙏᴍᴹᵐ⒨ḾМṀ௱ⅯℳΜϺᛖӍӎ𝐌𝑀𝑴𝓜𝔐𝕄𝕸𝖬𝗠𝘔𝙈𝙼𝚳𝛭𝜧𝝡𝞛🇲"), + Map.entry("m", "₥ᵯ𝖒𝐦𝗆𝔪𝕞𝓂ⓜmനᙢ൩ḿṁⅿϻṃጠɱ៳ᶆ𝙢𝓶𝚖𝑚𝗺᧕᧗"), + Map.entry("N", "𝇙𝇚𝇜🄝𝆧𝙉🄽ℕꓠ𝛮𝝢𝙽𝚴𝑵𝑁Ⲛ𝐍𝒩𝞜𝗡𝘕𝜨𝓝𝖭🅽₦🅝ЙЍⓃҋ៷NᴎɴƝᑎ几иՈռИהЛπᴺᶰŃ刀ክṄⁿÑПΝᴨոϖǸŇṆŅṊṈทŊӢӣӤӥћѝйᥢҊᴻ🇳"), + Map.entry("n", "ոռח𝒏𝓷𝙣𝑛𝖓𝔫𝗇𝚗𝗻ᥒⓝήnǹᴒńñᾗηṅňṇɲņṋṉղຖՌƞŋ⒩ภกɳпʼnлԉȠἠἡῃդᾐᾑᾒᾓᾔᾕᾖῄῆῇῂἢἣἤἥἦἧὴήበቡቢባቤብቦȵ𝛈𝜂𝜼𝝶𝞰𝕟𝘯𝐧𝓃ᶇᵰᥥ∩"), + Map.entry("O", "𝜽⭘🔿ꭴ⭕⏺🄁🄀Ꭴ𝚯𝚹𝛩𝛳𝜣𝜭𝝝𝝧𝞗𝞡ⴱᎾᏫ⍬𝞱𝝷𝛉𝟎𝜃θ𝟘𝑂𝑶𝓞𝔒𝕆𝕺𝗢𝘖𝙊𝛰㈇ꄲ🄞🔾🄾𐊒𝟬ꓳⲞ𐐄𐊫𐓂𝞞🅞⍥◯ⵁ⊖0⊝𝝤Ѳϴ𝚶𝜪ѺӦӨӪΌʘ𝐎ǑÒŎÓÔÕȌȎㇿ❍ⓄOὋロ❤૦⊕ØФԾΘƠᴼᵒ⒪ŐÖₒ¤◊Φ〇ΟОՕଠഠ௦סỒỐỖỔṌȬṎŌṐṒȮȰȪỎỜỚỠỞỢỌỘǪǬǾƟⵔ߀៰⍜⎔⎕⦰⦱⦲⦳⦴⦵⦶⦷⦸⦹⦺⦻⦼⦽⦾⦿⧀⧁⧂⧃ὈὉὊὌὍ"), + Map.entry("o", "ంಂംං૦௦۵ℴ𝑜𝒐𝖔ꬽ𝝄𝛔𝜎𝝈𝞂ჿ𝚘০୦ዐ𝛐𝗈𝞼ဝⲟ𝙤၀𐐬𝔬𐓪𝓸🇴⍤○ϙ🅾𝒪𝖮𝟢𝟶𝙾𝘰𝗼𝕠𝜊𝐨𝝾𝞸ᐤⓞѳ᧐ᥲðoఠᦞՓòөӧóºōôǒȏŏồốȍỗổõσṍȭṏὄṑṓȯȫ๏ᴏőöѻоዐǭȱ০୦٥౦೦൦๐໐οօᴑ०੦ỏơờớỡởợọộǫøǿɵծὀὁόὸόὂὃὅ"), + Map.entry("P", "🄟🄿ꓑ𝚸𝙿𝞠𝙋ꮲⲢ𝒫𝝦𝑃𝑷𝗣𝐏𐊕𝜬𝘗𝓟𝖯𝛲Ꮲ🅟Ҏ🅿ⓅPƤᑭ尸Ṗրφքᴘᴾᵖ⒫ṔアקРየᴩⱣℙΡῬᑸᑶᑷᑹᑬᑮ🇵₱"), + Map.entry("p", "ⲣҏ℗ⓟpṕṗƥᵽῥρрƿǷῤ⍴𝓹𝓅𝐩𝑝𝒑𝔭𝕡𝖕𝗉𝗽𝘱𝙥𝚙𝛒𝝆𝞺𝜌𝞀"), + Map.entry("Q", "🅀🄠Ꝗ🆀🅠ⓆQℚⵕԚ𝐐𝑄𝑸𝒬𝓠𝚀𝘘𝙌𝖰𝕼𝔔𝗤🇶"), + Map.entry("q", "𝓆ꝗ𝗾ⓠqգ⒬۹զᑫɋɊԛ𝗊𝑞𝘲𝕢𝚚𝒒𝖖𝐪𝔮𝓺𝙦"), + Map.entry("R", "℞🄡℟ꭱᏒ𐒴ꮢᎡꓣ🆁🅡ⓇRᴙȒʀᖇя尺ŔЯરƦᴿዪṚɌʁℛℜℝṘŘȐṜŖṞⱤ𝐑𝑅𝑹𝓡𝕽𝖱𝗥𝘙𝙍𝚁ᚱ🇷ᴚ"), + Map.entry("r", "𝚛ꭇᣴℾ𝚪𝛤𝜞𝝘𝞒ⲄГᎱᒥꭈⲅꮁⓡrŕṙřȑȓṛṝŗгՐɾᥬṟɍʳ⒭ɼѓᴦᶉ𝐫𝑟𝒓𝓇𝓻𝔯𝕣𝖗𝗋𝗿𝘳𝙧ᵲґᵣ"), + Map.entry("S", "🅂🄪🄢ꇙ𝓢𝗦Ꮪ𝒮Ꮥ𝚂𝐒ꓢ𝖲𝔖𝙎𐊖𝕾𐐠𝘚𝕊𝑆𝑺🆂🅢ⓈSṨŞֆՏȘˢ⒮ЅṠŠŚṤŜṦṢടᔕᔖᔢᔡᔣᔤ"), + Map.entry("s", "ᣵⓢꜱ𐑈ꮪsśṥŝṡšṧʂṣṩѕşșȿᶊక𝐬𝑠𝒔𝓈𝓼𝔰𝕤𝖘𝗌𝘀𝘴𝙨𝚜ގ🇸"), + Map.entry("T", "🅃🄣七ፒ𝜯🆃𐌕𝚻𝛵𝕋𝕿𝑻𐊱𐊗𝖳𝙏🝨𝝩𝞣𝚃𝘛𝑇ꓔ⟙𝐓Ⲧ𝗧⊤𝔗Ꭲꭲ𝒯🅣⏇⏉ⓉTтҬҭƬイŦԵτᴛᵀイፕϮŤ⊥ƮΤТ下ṪṬȚŢṰṮ丅丁ᐪ𝛕𝜏𝝉𝞃𝞽𝓣ㄒ🇹ጥ"), + Map.entry("t", "ⓣtṫẗťṭțȶ੮էʇ†ţṱṯƭŧᵗ⒯ʈեƫ𝐭𝑡𝒕𝓉𝓽𝔱𝕥𝖙𝗍𝘁𝘵𝙩𝚝ナ"), + Map.entry("U", "🅄Џ🄤ሀꓴ𐓎꒤🆄🅤ŨŬŮᑗᑘǓǕǗǙⓊUȖᑌ凵ƱմԱꓵЦŪՄƲᙀᵁᵘ⒰ŰપÜՍÙÚÛṸṺǛỦȔƯỪỨỮỬỰỤṲŲṶṴɄᥩᑧ∪ᘮ⋃𝐔𝑈𝑼𝒰𝓤𝔘𝕌𝖀𝖴𝗨𝘜𝙐𝚄🇺"), + Map.entry("u", "𝘂𝘶𝙪𝚞ꞟꭎꭒ𝛖𝜐𝝊𝞄𝞾𐓶ὺύⓤuùũūừṷṹŭǖữᥙǚǜὗυΰนսʊǘǔúůᴜűųยûṻцሁüᵾᵤµʋủȕȗưứửựụṳṵʉῠῡῢΰῦῧὐὑϋύὒὓὔὕὖᥔ𝐮𝑢𝒖𝓊𝓾𝔲𝕦𝖚𝗎ᶙ"), + Map.entry("V", "𝑉𝒱𝕍𝗩🄥🅅ꓦ𝑽𝖵𝘝Ꮩ𝚅𝙑𝐕🆅🅥ⓋVᐯѴᵛ⒱۷ṾⅴⅤṼ٧ⴸѶᐺᐻ🇻𝓥"), + Map.entry("v", "∨⌄⋁ⅴ𝐯𝑣𝒗𝓋𝔳𝕧𝖛𝗏ꮩሀⓥv𝜐𝝊ṽṿ౮งѵעᴠνטᵥѷ៴ᘁ𝙫𝚟𝛎𝜈𝝂𝝼𝞶𝘷𝘃𝓿"), + Map.entry("W", "𝐖𝑊𝓦𝔚𝕎𝖂𝖶𝗪𝙒𝚆🄦🅆ᏔᎳ𝑾ꓪ𝒲𝘞🆆Ⓦ🅦wWẂᾧᗯᥕ山ѠຟచաЩШώщฬшᙎᵂʷ⒲ฝሠẄԜẀŴẆẈധᘺѿᙡƜ₩🇼"), + Map.entry("w", "𝐰ꝡ𝑤𝒘𝓌𝔀𝔴𝕨𝖜𝗐𝘄𝘸𝙬𝚠աẁꮃẃⓦ⍵ŵẇẅẘẉⱳὼὠὡὢὣωὤὥὦὧῲῳῴῶῷⱲѡԝᴡώᾠᾡᾢᾣᾤᾥᾦɯ𝝕𝟉𝞏"), + Map.entry("X", "ꭓꭕ𝛘𝜒𝝌𝞆𝟀ⲭ🞨𝑿𝛸🄧🞩🞪🅇🞫🞬𐌗Ⲭꓫ𝖃𝞦𝘟𐊐𝚾𝝬𝜲Ꭓ𐌢𝖷𝑋𝕏𝔛𐊴𝗫🆇🅧❌Ⓧ𝓧XẊ᙭χㄨ𝒳ӾჯӼҳЖΧҲᵡˣ⒳אሸẌꊼⅩХ╳᙮ᕁᕽⅹᚷⵝ𝙓𝚇乂𝐗🇽"), + Map.entry("x", "᙮ⅹ𝑥𝒙𝓍𝔵𝕩𝖝𝗑𝘅ᕁᕽⓧxхẋ×ₓ⤫⤬⨯ẍᶍ𝙭ӽ𝘹𝐱𝚡⨰メ𝔁"), + Map.entry("Y", "𝒴🄨𝓨𝔜𝖄𝖸𝘠𝙔𝚼𝛶𝝪𝞤УᎩᎽⲨ𝚈𝑌𝗬𝐘ꓬ𝒀𝜰𐊲🆈🅨ⓎYὛƳㄚʏ⅄ϔ¥¥ՎϓγץӲЧЎሃŸɎϤΥϒҮỲÝŶỸȲẎỶỴῨῩῪΎὙὝὟΫΎӮӰҰұ𝕐🇾"), + Map.entry("y", "𝐲𝑦𝒚𝓎𝔂𝔶𝕪𝖞𝗒𝘆𝘺𝙮𝚢ʏỿꭚγℽ𝛄𝛾𝜸𝝲𝞬🅈ᎽᎩⓨyỳýŷỹȳẏÿỷуყẙỵƴɏᵞɣʸᶌү⒴ӳӱӯўУʎ"), + Map.entry("Z", "🄩🅉ꓜ𝗭𝐙☡Ꮓ𝘡🆉🅩ⓏZẔƵ乙ẐȤᶻ⒵ŹℤΖŻŽẒⱫ🇿"), + Map.entry("z", "𝑍𝒁𝒵𝓩𝖹𝙕𝚉𝚭𝛧𝜡𝝛𝞕ᵶꮓ𝐳𝑧𝒛𝓏𝔃𝔷𝕫𝖟𝗓𝘇𝘻𝙯𝚣ⓩzźẑżžẓẕƶȥɀᴢጊʐⱬᶎʑᙆ") + ); + + public static String normalize(String orig) { + StringBuilder normalized = new StringBuilder(); + for (char c : orig.toCharArray()) { + String cStr = Character.toString(c); + normalized.append( + CONFUSABLES + .entrySet() + .stream() + .filter(e -> e.getValue().contains(cStr)) + .map(Map.Entry::getKey) + .findFirst().orElse(cStr) + ); + } + return normalized.toString(); + } +}