From edc457d5f56da02aaa2af9488ccb9adcfb8639ec Mon Sep 17 00:00:00 2001 From: P0nk Date: Mon, 27 Sep 2021 22:00:42 +0200 Subject: [PATCH 1/5] Implement "view blacklist" packet --- .../channel/handlers/PlayerInteractionHandler.java | 11 ++++++++++- src/main/java/tools/PacketCreator.java | 13 +++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/main/java/net/server/channel/handlers/PlayerInteractionHandler.java b/src/main/java/net/server/channel/handlers/PlayerInteractionHandler.java index 75e90ae749..0471a672fd 100644 --- a/src/main/java/net/server/channel/handlers/PlayerInteractionHandler.java +++ b/src/main/java/net/server/channel/handlers/PlayerInteractionHandler.java @@ -44,6 +44,7 @@ import tools.PacketCreator; import java.awt.*; import java.sql.SQLException; import java.util.Arrays; +import java.util.List; /** * @author Matze @@ -83,7 +84,9 @@ public final class PlayerInteractionHandler extends AbstractPacketHandler { MERCHANT_MESO(0x2B), SOMETHING(0x2D), VIEW_VISITORS(0x2E), - BLACKLIST(0x2F), + VIEW_BLACKLIST(0x2F), + ADD_TO_BLACKLIST(0x30), + REMOVE_FROM_BLACKLIST(0x31), REQUEST_TIE(0x32), ANSWER_TIE(0x33), GIVE_UP(0x34), @@ -681,6 +684,12 @@ public final class PlayerInteractionHandler extends AbstractPacketHandler { } merchant.withdrawMesos(chr); + + } else if (mode == Action.VIEW_BLACKLIST.getCode()) { + List blacklistedNames = List.of("Blanca", "Betsy", "Kevin", "Rosa", "Evan", "Terence", + "Cecilia", "Gayle", "Erma", "Dorothy", "Willis", "Alberta", "Marilyn", "Myron", "Sheryl", + "Marco", "Jose", "Kendra", "Laurence", "Victoria", "NonListed"); + c.sendPacket(PacketCreator.viewBlacklist(blacklistedNames)); } else if (mode == Action.MERCHANT_ORGANIZE.getCode()) { HiredMerchant merchant = chr.getHiredMerchant(); if (merchant == null || !merchant.isOwner(chr)) { diff --git a/src/main/java/tools/PacketCreator.java b/src/main/java/tools/PacketCreator.java index 0f92d43dae..ca375525d4 100644 --- a/src/main/java/tools/PacketCreator.java +++ b/src/main/java/tools/PacketCreator.java @@ -5203,6 +5203,19 @@ public class PacketCreator { return p; } + /** + * @param chrNames Blacklisted names. The first 20 names will be displayed, anything beyond does no difference. + */ + public static Packet viewBlacklist(List chrNames) { + final OutPacket p = OutPacket.create(SendOpcode.PLAYER_INTERACTION); + p.writeByte(PlayerInteractionHandler.Action.VIEW_BLACKLIST.getCode()); + p.writeShort(chrNames.size()); + for (String chrName : chrNames) { + p.writeString(chrName); + } + return p; + } + public static Packet hiredMerchantVisitorAdd(Character chr, int slot) { final OutPacket p = OutPacket.create(SendOpcode.PLAYER_INTERACTION); p.writeByte(PlayerInteractionHandler.Action.VISIT.getCode()); From cc23d7734ad8598921030c4f637182866a7e4054 Mon Sep 17 00:00:00 2001 From: P0nk Date: Mon, 27 Sep 2021 22:54:05 +0200 Subject: [PATCH 2/5] Implement "view visitor" packet --- .../handlers/PlayerInteractionHandler.java | 6 +++++- src/main/java/tools/PacketCreator.java | 18 +++++++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/main/java/net/server/channel/handlers/PlayerInteractionHandler.java b/src/main/java/net/server/channel/handlers/PlayerInteractionHandler.java index 0471a672fd..fa8d198d80 100644 --- a/src/main/java/net/server/channel/handlers/PlayerInteractionHandler.java +++ b/src/main/java/net/server/channel/handlers/PlayerInteractionHandler.java @@ -685,11 +685,15 @@ public final class PlayerInteractionHandler extends AbstractPacketHandler { merchant.withdrawMesos(chr); + } else if (mode == Action.VIEW_VISITORS.getCode()) { + List visitorNames = List.of("Dwayne", "Ruben", "Ada", "Clifton", "Beatrice", "Kent", "Max", + "Cecelia", "Edward", "Cory"); + c.sendPacket(PacketCreator.viewMerchantVisitors(visitorNames)); } else if (mode == Action.VIEW_BLACKLIST.getCode()) { List blacklistedNames = List.of("Blanca", "Betsy", "Kevin", "Rosa", "Evan", "Terence", "Cecilia", "Gayle", "Erma", "Dorothy", "Willis", "Alberta", "Marilyn", "Myron", "Sheryl", "Marco", "Jose", "Kendra", "Laurence", "Victoria", "NonListed"); - c.sendPacket(PacketCreator.viewBlacklist(blacklistedNames)); + c.sendPacket(PacketCreator.viewMerchantBlacklist(blacklistedNames)); } else if (mode == Action.MERCHANT_ORGANIZE.getCode()) { HiredMerchant merchant = chr.getHiredMerchant(); if (merchant == null || !merchant.isOwner(chr)) { diff --git a/src/main/java/tools/PacketCreator.java b/src/main/java/tools/PacketCreator.java index ca375525d4..e3c372d070 100644 --- a/src/main/java/tools/PacketCreator.java +++ b/src/main/java/tools/PacketCreator.java @@ -76,6 +76,7 @@ import java.sql.SQLException; import java.util.List; import java.util.*; import java.util.Map.Entry; +import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; /** @@ -5203,10 +5204,25 @@ public class PacketCreator { return p; } + /** + * @param chrNames Merchant visitors. The first 10 names will be shown, + * everything beyond will layered over each other at the top of the window. + */ + public static Packet viewMerchantVisitors(List chrNames) { + final OutPacket p = OutPacket.create(SendOpcode.PLAYER_INTERACTION); + p.writeByte(PlayerInteractionHandler.Action.VIEW_VISITORS.getCode()); + p.writeShort(chrNames.size()); + for (String chrName : chrNames) { + p.writeString(chrName); + p.writeInt((int) (TimeUnit.HOURS.toMillis(1) + TimeUnit.MINUTES.toMillis(23))); // milliseconds, displayed as hours+minutes + } + return p; + } + /** * @param chrNames Blacklisted names. The first 20 names will be displayed, anything beyond does no difference. */ - public static Packet viewBlacklist(List chrNames) { + public static Packet viewMerchantBlacklist(List chrNames) { final OutPacket p = OutPacket.create(SendOpcode.PLAYER_INTERACTION); p.writeByte(PlayerInteractionHandler.Action.VIEW_BLACKLIST.getCode()); p.writeShort(chrNames.size()); From 3a9305d0d9d66038fffe98d6cfdf1f99a84e664f Mon Sep 17 00:00:00 2001 From: P0nk Date: Tue, 28 Sep 2021 08:33:25 +0200 Subject: [PATCH 3/5] Store merchant visitor history, and display it in the dialogue --- .../handlers/PlayerInteractionHandler.java | 11 ++-- src/main/java/server/maps/HiredMerchant.java | 62 ++++++++++++++----- src/main/java/tools/PacketCreator.java | 17 +++-- 3 files changed, 59 insertions(+), 31 deletions(-) diff --git a/src/main/java/net/server/channel/handlers/PlayerInteractionHandler.java b/src/main/java/net/server/channel/handlers/PlayerInteractionHandler.java index fa8d198d80..fbb8c757cc 100644 --- a/src/main/java/net/server/channel/handlers/PlayerInteractionHandler.java +++ b/src/main/java/net/server/channel/handlers/PlayerInteractionHandler.java @@ -312,8 +312,7 @@ public final class PlayerInteractionHandler extends AbstractPacketHandler { } else { chr.sendPacket(PacketCreator.getMiniRoomError(22)); } - } else if (ob instanceof HiredMerchant && chr.getHiredMerchant() == null) { - HiredMerchant merchant = (HiredMerchant) ob; + } else if (ob instanceof HiredMerchant merchant && chr.getHiredMerchant() == null) { merchant.visitShop(chr); } } @@ -686,9 +685,11 @@ public final class PlayerInteractionHandler extends AbstractPacketHandler { merchant.withdrawMesos(chr); } else if (mode == Action.VIEW_VISITORS.getCode()) { - List visitorNames = List.of("Dwayne", "Ruben", "Ada", "Clifton", "Beatrice", "Kent", "Max", - "Cecelia", "Edward", "Cory"); - c.sendPacket(PacketCreator.viewMerchantVisitors(visitorNames)); + HiredMerchant merchant = chr.getHiredMerchant(); + if (merchant == null || !merchant.isOwner(chr)) { + return; + } + c.sendPacket(PacketCreator.viewMerchantVisitorHistory(merchant.getVisitorHistory())); } else if (mode == Action.VIEW_BLACKLIST.getCode()) { List blacklistedNames = List.of("Blanca", "Betsy", "Kevin", "Rosa", "Evan", "Terence", "Cecilia", "Gayle", "Erma", "Dorothy", "Willis", "Alberta", "Marilyn", "Myron", "Sheryl", diff --git a/src/main/java/server/maps/HiredMerchant.java b/src/main/java/server/maps/HiredMerchant.java index 473bfb435f..c8010172f5 100644 --- a/src/main/java/server/maps/HiredMerchant.java +++ b/src/main/java/server/maps/HiredMerchant.java @@ -45,6 +45,8 @@ import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; +import java.time.Duration; +import java.time.Instant; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedList; @@ -57,6 +59,9 @@ import java.util.concurrent.locks.Lock; * @author Ronan - concurrency protection */ public class HiredMerchant extends AbstractMapObject { + private static final int VISITOR_HISTORY_LIMIT = 10; + private static final int BLACKLIST_LIMIT = 20; + private final int ownerId; private final int itemId; private final int mesos = 0; @@ -65,15 +70,20 @@ public class HiredMerchant extends AbstractMapObject { private final long start; private String ownerName = ""; private String description = ""; - private final Character[] visitors = new Character[3]; private final List items = new LinkedList<>(); private final List> messages = new LinkedList<>(); private final List sold = new LinkedList<>(); private final AtomicBoolean open = new AtomicBoolean(); private boolean published = false; private MapleMap map; + private final Visitor[] visitors = new Visitor[3]; + private final LinkedList visitorHistory = new LinkedList<>(); private final Lock visitorLock = MonitoredReentrantLockFactory.createLock(MonitoredLockType.VISITOR_MERCH, true); + private record Visitor(Character chr, Instant enteredAt) {} + + public record PastVisitor(String chrName, Duration visitDuration) {} + public HiredMerchant(final Character owner, String desc, int itemId) { this.setPosition(owner.getPosition()); this.start = System.currentTimeMillis(); @@ -96,9 +106,9 @@ public class HiredMerchant extends AbstractMapObject { } private void broadcastToVisitors(Packet packet) { - for (Character visitor : visitors) { + for (Visitor visitor : visitors) { if (visitor != null) { - visitor.sendPacket(packet); + visitor.chr.sendPacket(packet); } } } @@ -108,7 +118,7 @@ public class HiredMerchant extends AbstractMapObject { try { byte count = 0; if (this.isOpen()) { - for (Character visitor : visitors) { + for (Visitor visitor : visitors) { if (visitor != null) { count++; } @@ -128,7 +138,7 @@ public class HiredMerchant extends AbstractMapObject { try { int i = this.getFreeSlot(); if (i > -1) { - visitors[i] = visitor; + visitors[i] = new Visitor(visitor, Instant.now()); broadcastToVisitors(PacketCreator.hiredMerchantVisitorAdd(visitor, i + 1)); this.getMap().broadcastMessage(PacketCreator.updateHiredMerchantBox(this)); @@ -141,15 +151,18 @@ public class HiredMerchant extends AbstractMapObject { } } - public void removeVisitor(Character visitor) { + public void removeVisitor(Character chr) { visitorLock.lock(); try { - int slot = getVisitorSlot(visitor); + int slot = getVisitorSlot(chr); if (slot < 0) { //Not found return; } - if (visitors[slot] != null && visitors[slot].getId() == visitor.getId()) { + + Visitor visitor = visitors[slot]; + if (visitor != null && visitor.chr.getId() == chr.getId()) { visitors[slot] = null; + addVisitorToHistory(visitor); broadcastToVisitors(PacketCreator.hiredMerchantVisitorLeave(slot + 1)); this.getMap().broadcastMessage(PacketCreator.updateHiredMerchantBox(this)); } @@ -158,6 +171,14 @@ public class HiredMerchant extends AbstractMapObject { } } + private void addVisitorToHistory(Visitor visitor) { + Duration visitDuration = Duration.between(visitor.enteredAt, Instant.now()); + visitorHistory.addFirst(new PastVisitor(visitor.chr.getName(), visitDuration)); + while (visitorHistory.size() > VISITOR_HISTORY_LIMIT) { + visitorHistory.removeLast(); + } + } + public int getVisitorSlotThreadsafe(Character visitor) { visitorLock.lock(); try { @@ -169,7 +190,7 @@ public class HiredMerchant extends AbstractMapObject { private int getVisitorSlot(Character visitor) { for (int i = 0; i < 3; i++) { - if (visitors[i] != null && visitors[i].getId() == visitor.getId()) { + if (visitors[i] != null && visitors[i].chr.getId() == visitor.getId()) { return i; } } @@ -180,15 +201,15 @@ public class HiredMerchant extends AbstractMapObject { visitorLock.lock(); try { for (int i = 0; i < 3; i++) { - Character visitor = visitors[i]; + Visitor visitor = visitors[i]; if (visitor != null) { - visitor.setHiredMerchant(null); - - visitor.sendPacket(PacketCreator.leaveHiredMerchant(i + 1, 0x11)); - visitor.sendPacket(PacketCreator.hiredMerchantMaintenanceMessage()); - + final Character visitorChr = visitor.chr; + visitorChr.setHiredMerchant(null); + visitorChr.sendPacket(PacketCreator.leaveHiredMerchant(i + 1, 0x11)); + visitorChr.sendPacket(PacketCreator.hiredMerchantMaintenanceMessage()); visitors[i] = null; + addVisitorToHistory(visitor); } } @@ -498,12 +519,15 @@ public class HiredMerchant extends AbstractMapObject { return description; } - public Character[] getVisitors() { + public Character[] getVisitorCharacters() { visitorLock.lock(); try { Character[] copy = new Character[3]; for (int i = 0; i < visitors.length; i++) { - copy[i] = visitors[i]; + Visitor visitor = visitors[i]; + if (visitor != null) { + copy[i] = visitor.chr; + } } return copy; @@ -694,6 +718,10 @@ public class HiredMerchant extends AbstractMapObject { } } + public List getVisitorHistory() { + return Collections.unmodifiableList(visitorHistory); + } + public int getMapId() { return map.getId(); } diff --git a/src/main/java/tools/PacketCreator.java b/src/main/java/tools/PacketCreator.java index e3c372d070..ce2a95acf9 100644 --- a/src/main/java/tools/PacketCreator.java +++ b/src/main/java/tools/PacketCreator.java @@ -76,7 +76,6 @@ import java.sql.SQLException; import java.util.List; import java.util.*; import java.util.Map.Entry; -import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; /** @@ -5090,7 +5089,7 @@ public class PacketCreator { p.writeInt(hm.getItemId()); p.writeString("Hired Merchant"); - Character[] visitors = hm.getVisitors(); + Character[] visitors = hm.getVisitorCharacters(); for (int i = 0; i < 3; i++) { if (visitors[i] != null) { p.writeByte(i + 1); @@ -5205,16 +5204,16 @@ public class PacketCreator { } /** - * @param chrNames Merchant visitors. The first 10 names will be shown, - * everything beyond will layered over each other at the top of the window. + * @param pastVisitors Merchant visitors. The first 10 names will be shown, + * everything beyond will layered over each other at the top of the window. */ - public static Packet viewMerchantVisitors(List chrNames) { + public static Packet viewMerchantVisitorHistory(List pastVisitors) { final OutPacket p = OutPacket.create(SendOpcode.PLAYER_INTERACTION); p.writeByte(PlayerInteractionHandler.Action.VIEW_VISITORS.getCode()); - p.writeShort(chrNames.size()); - for (String chrName : chrNames) { - p.writeString(chrName); - p.writeInt((int) (TimeUnit.HOURS.toMillis(1) + TimeUnit.MINUTES.toMillis(23))); // milliseconds, displayed as hours+minutes + p.writeShort(pastVisitors.size()); + for (HiredMerchant.PastVisitor pastVisitor : pastVisitors) { + p.writeString(pastVisitor.chrName()); + p.writeInt((int) pastVisitor.visitDuration().toMillis()); // milliseconds, displayed as hours and minutes } return p; } From 5cae3fc3366fa504ad626d1aec4e029ec42a855f Mon Sep 17 00:00:00 2001 From: P0nk Date: Tue, 28 Sep 2021 18:26:27 +0200 Subject: [PATCH 4/5] Implement merchant blacklist, blocking any listed characters from entering --- .../handlers/PlayerInteractionHandler.java | 28 +++++++++--- src/main/java/server/maps/HiredMerchant.java | 43 +++++++++++++++++-- src/main/java/tools/PacketCreator.java | 2 +- 3 files changed, 61 insertions(+), 12 deletions(-) diff --git a/src/main/java/net/server/channel/handlers/PlayerInteractionHandler.java b/src/main/java/net/server/channel/handlers/PlayerInteractionHandler.java index fbb8c757cc..8fdfa362cb 100644 --- a/src/main/java/net/server/channel/handlers/PlayerInteractionHandler.java +++ b/src/main/java/net/server/channel/handlers/PlayerInteractionHandler.java @@ -44,7 +44,6 @@ import tools.PacketCreator; import java.awt.*; import java.sql.SQLException; import java.util.Arrays; -import java.util.List; /** * @author Matze @@ -286,8 +285,7 @@ public final class PlayerInteractionHandler extends AbstractPacketHandler { int oid = p.readInt(); MapObject ob = chr.getMap().getMapObject(oid); - if (ob instanceof PlayerShop) { - PlayerShop shop = (PlayerShop) ob; + if (ob instanceof PlayerShop shop) { shop.visitShop(chr); } else if (ob instanceof MiniGame) { p.skip(1); @@ -691,10 +689,26 @@ public final class PlayerInteractionHandler extends AbstractPacketHandler { } c.sendPacket(PacketCreator.viewMerchantVisitorHistory(merchant.getVisitorHistory())); } else if (mode == Action.VIEW_BLACKLIST.getCode()) { - List blacklistedNames = List.of("Blanca", "Betsy", "Kevin", "Rosa", "Evan", "Terence", - "Cecilia", "Gayle", "Erma", "Dorothy", "Willis", "Alberta", "Marilyn", "Myron", "Sheryl", - "Marco", "Jose", "Kendra", "Laurence", "Victoria", "NonListed"); - c.sendPacket(PacketCreator.viewMerchantBlacklist(blacklistedNames)); + HiredMerchant merchant = chr.getHiredMerchant(); + if (merchant == null || !merchant.isOwner(chr)) { + return; + } + + c.sendPacket(PacketCreator.viewMerchantBlacklist(merchant.getBlacklist())); + } else if (mode == Action.ADD_TO_BLACKLIST.getCode()) { + HiredMerchant merchant = chr.getHiredMerchant(); + if (merchant == null || !merchant.isOwner(chr)) { + return; + } + String chrName = p.readString(); + merchant.addToBlacklist(chrName); + } else if (mode == Action.REMOVE_FROM_BLACKLIST.getCode()) { + HiredMerchant merchant = chr.getHiredMerchant(); + if (merchant == null || !merchant.isOwner(chr)) { + return; + } + String chrName = p.readString(); + merchant.removeFromBlacklist(chrName); } else if (mode == Action.MERCHANT_ORGANIZE.getCode()) { HiredMerchant merchant = chr.getHiredMerchant(); if (merchant == null || !merchant.isOwner(chr)) { diff --git a/src/main/java/server/maps/HiredMerchant.java b/src/main/java/server/maps/HiredMerchant.java index c8010172f5..dfd9bc9668 100644 --- a/src/main/java/server/maps/HiredMerchant.java +++ b/src/main/java/server/maps/HiredMerchant.java @@ -47,10 +47,7 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.time.Duration; import java.time.Instant; -import java.util.ArrayList; -import java.util.Collections; -import java.util.LinkedList; -import java.util.List; +import java.util.*; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.locks.Lock; @@ -78,6 +75,7 @@ public class HiredMerchant extends AbstractMapObject { private MapleMap map; private final Visitor[] visitors = new Visitor[3]; private final LinkedList visitorHistory = new LinkedList<>(); + private final LinkedHashSet blacklist = new LinkedHashSet<>(); // case-sensitive character names private final Lock visitorLock = MonitoredReentrantLockFactory.createLock(MonitoredLockType.VISITOR_MERCH, true); private record Visitor(Character chr, Instant enteredAt) {} @@ -489,6 +487,9 @@ public class HiredMerchant extends AbstractMapObject { } else if (!this.isOpen()) { chr.sendPacket(PacketCreator.getMiniRoomError(18)); return; + } else if (isBlacklisted(chr.getName())) { + chr.sendPacket(PacketCreator.getMiniRoomError(17)); + return; } else if (!this.addVisitor(chr)) { chr.sendPacket(PacketCreator.getMiniRoomError(2)); return; @@ -722,6 +723,40 @@ public class HiredMerchant extends AbstractMapObject { return Collections.unmodifiableList(visitorHistory); } + public void addToBlacklist(String chrName) { + visitorLock.lock(); + try { + if (blacklist.size() >= BLACKLIST_LIMIT) { + return; + } + blacklist.add(chrName); + } finally { + visitorLock.unlock(); + } + } + + public void removeFromBlacklist(String chrName) { + visitorLock.lock(); + try { + blacklist.remove(chrName); + } finally { + visitorLock.unlock(); + } + } + + public Set getBlacklist() { + return Collections.unmodifiableSet(blacklist); + } + + private boolean isBlacklisted(String chrName) { + visitorLock.lock(); + try { + return blacklist.contains(chrName); + } finally { + visitorLock.unlock(); + } + } + public int getMapId() { return map.getId(); } diff --git a/src/main/java/tools/PacketCreator.java b/src/main/java/tools/PacketCreator.java index ce2a95acf9..7227677280 100644 --- a/src/main/java/tools/PacketCreator.java +++ b/src/main/java/tools/PacketCreator.java @@ -5221,7 +5221,7 @@ public class PacketCreator { /** * @param chrNames Blacklisted names. The first 20 names will be displayed, anything beyond does no difference. */ - public static Packet viewMerchantBlacklist(List chrNames) { + public static Packet viewMerchantBlacklist(Set chrNames) { final OutPacket p = OutPacket.create(SendOpcode.PLAYER_INTERACTION); p.writeByte(PlayerInteractionHandler.Action.VIEW_BLACKLIST.getCode()); p.writeShort(chrNames.size()); From 2e78742d8c00374d67373b8c48d3b2957e64ab28 Mon Sep 17 00:00:00 2001 From: P0nk Date: Tue, 28 Sep 2021 18:43:31 +0200 Subject: [PATCH 5/5] Update development status --- README.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index d581445d93..28ef99b2fd 100644 --- a/README.md +++ b/README.md @@ -5,14 +5,18 @@ This document is currently being worked on, so it may not be fully accurate. ## Beware -***This emulator is not production ready.*** +***This emulator is not production ready.*** -It can be useful for testing things locally or for trying out ideas, but launching a new private server based on this with no real changes is not recommended. +It can be useful for testing things locally or for trying out ideas, but launching a new private server based on this +with no real changes is not recommended. --- + ### Development information -#### Status -The current status is: *in development and gladly accepting contributions* + +#### Status (updated 28/9/21) + +Development is **on pause**, but any submitted PRs will be reviewed. #### Ways to contribute @@ -21,6 +25,7 @@ The current status is: *in development and gladly * Spread the word about Cosmic #### Community + GitHub: https://github.com/P0nk/Cosmic Discord: https://discord.gg/JU5aQapVZK