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
diff --git a/src/main/java/net/server/channel/handlers/PlayerInteractionHandler.java b/src/main/java/net/server/channel/handlers/PlayerInteractionHandler.java
index 75e90ae749..8fdfa362cb 100644
--- a/src/main/java/net/server/channel/handlers/PlayerInteractionHandler.java
+++ b/src/main/java/net/server/channel/handlers/PlayerInteractionHandler.java
@@ -83,7 +83,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),
@@ -283,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);
@@ -309,8 +310,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);
}
}
@@ -681,6 +681,34 @@ public final class PlayerInteractionHandler extends AbstractPacketHandler {
}
merchant.withdrawMesos(chr);
+
+ } else if (mode == Action.VIEW_VISITORS.getCode()) {
+ HiredMerchant merchant = chr.getHiredMerchant();
+ if (merchant == null || !merchant.isOwner(chr)) {
+ return;
+ }
+ c.sendPacket(PacketCreator.viewMerchantVisitorHistory(merchant.getVisitorHistory()));
+ } else if (mode == Action.VIEW_BLACKLIST.getCode()) {
+ 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 473bfb435f..dfd9bc9668 100644
--- a/src/main/java/server/maps/HiredMerchant.java
+++ b/src/main/java/server/maps/HiredMerchant.java
@@ -45,10 +45,9 @@ import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.LinkedList;
-import java.util.List;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.locks.Lock;
@@ -57,6 +56,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 +67,21 @@ 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 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) {}
+
+ 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 +104,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 +116,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 +136,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 +149,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 +169,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 +188,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 +199,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);
}
}
@@ -468,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;
@@ -498,12 +520,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 +719,44 @@ public class HiredMerchant extends AbstractMapObject {
}
}
+ public List getVisitorHistory() {
+ 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 0f92d43dae..7227677280 100644
--- a/src/main/java/tools/PacketCreator.java
+++ b/src/main/java/tools/PacketCreator.java
@@ -5089,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);
@@ -5203,6 +5203,34 @@ public class PacketCreator {
return p;
}
+ /**
+ * @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 viewMerchantVisitorHistory(List pastVisitors) {
+ final OutPacket p = OutPacket.create(SendOpcode.PLAYER_INTERACTION);
+ p.writeByte(PlayerInteractionHandler.Action.VIEW_VISITORS.getCode());
+ 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;
+ }
+
+ /**
+ * @param chrNames Blacklisted names. The first 20 names will be displayed, anything beyond does no difference.
+ */
+ 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());
+ 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());