Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Beyond a name and appearance, characters have traits, professions, injuries, and
- **Character profiles** — create and switch between characters, with race, traits, descriptions, and website-connected creation.
- **Roleplay conversation** — use local speech, whispers, shouts, actions, and out-of-character channels, with speech bubbles and channel preferences.
- **Identity and disguise** — show character identities in social interactions and support masks and alternate personas.
- **Mail recipient visibility** — `/rpcharacter mail` toggles whether your active character appears in BirdMessenger’s recipient list; `/rpcharacter mail off` hides it and `/rpcharacter mail on` restores it. Characters are listed by default, and the setting persists across logouts and restarts. Already-sent mail still arrives.
- **Character focus** — a shared, regenerating per-character resource used by Research and Magic.
- **Progression and rolls** — bring professions, attributes, and dice rolls into character gameplay.
- **Injuries and recovery** — represent injuries and prosthetics, with related treatment and progression systems.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
public final class CharCommand {

private static final Set<String> SUBCOMMANDS = Set.of(
"alias", "namecolour", "gender", "description", "profile", "override", "birthday");
"alias", "namecolour", "gender", "description", "profile", "override", "birthday", "mail");

private static final String FIELD_ALIAS = "alias";
private static final String FIELD_GENDER = "gender";
Expand All @@ -47,7 +47,7 @@ public static boolean isPersonaSubcommand(String subcommand) {
public static boolean handle(CommandSender sender, String label, String[] args) {
if (args.length == 0) {
RPTexts.send(sender, RPTexts.WARN + "Usage: /" + label
+ " <alias|namecolour|gender|description|profile|birthday|override>");
+ " <alias|namecolour|gender|description|profile|birthday|mail|override>");
return true;
}

Expand All @@ -69,6 +69,8 @@ public static boolean handle(CommandSender sender, String label, String[] args)
}

switch (sub) {
case "mail":
return handleMail(player, label, args);
case "alias":
return handleAlias(player, label, args);
case "namecolour":
Expand All @@ -81,11 +83,30 @@ public static boolean handle(CommandSender sender, String label, String[] args)
return handleBirthday(player, label, args);
default:
RPTexts.send(player, RPTexts.ERROR
+ "Unknown subcommand. Use alias, namecolour, gender, description, profile, or birthday.");
+ "Unknown subcommand. Use alias, namecolour, gender, description, profile, birthday, or mail.");
return true;
}
}

private static boolean handleMail(Player player, String label, String[] args) {
if (args.length > 2 || (args.length == 2
&& !args[1].equalsIgnoreCase("on") && !args[1].equalsIgnoreCase("off"))) {
RPTexts.send(player, RPTexts.WARN + "Usage: /" + label + " mail [on|off]");
return true;
}
RPCharacter character = requireActiveCharacter(player);
if (character == null) {
return true;
}
boolean listed = args.length == 1 ? !character.isMailListed() : args[1].equalsIgnoreCase("on");
character.setMailListed(listed);
RPCharacters.getPlayerManager().savePlayer(player);
RPTexts.send(player, RPTexts.SUCCESS + (listed
? "Your character is now listed as a mail recipient."
: "Your character is no longer listed as a mail recipient."));
return true;
}

private static boolean handleAlias(Player player, String label, String[] args) {
if (!player.hasPermission(Cache.personaSetPermission)) {
RPTexts.send(player, RPTexts.ERROR + "You do not have permission to change your alias.");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -682,6 +682,7 @@ private void loadPersonaFields(RPCharacter character, JSONObject characterJson)
if (characterJson.containsKey("slug")) {
character.setSlug((String) characterJson.get("slug"));
}
character.setMailListed(!"false".equalsIgnoreCase(String.valueOf(characterJson.get("mail-listed"))));
if (characterJson.containsKey("hidden")) {
character.setHidden(Boolean.parseBoolean(characterJson.get("hidden").toString()));
}
Expand Down Expand Up @@ -786,6 +787,7 @@ private void savePersonaFields(HashMap<String, Object> defaults, RPCharacter cha
if (character.getSlug() != null && !character.getSlug().isBlank()) {
defaults.put("slug", character.getSlug());
}
defaults.put("mail-listed", String.valueOf(character.isMailListed()));
if (character.isHidden()) {
defaults.put("hidden", "true");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,14 +81,15 @@ public static void upsert(UUID ownerUuid, RPCharacter character) {
if (ownerUuid == null || character == null || character.getId() == null) {
return;
}
if (character.getStatus() != Status.ALIVE) {
if (character.getStatus() != Status.ALIVE || !character.isMailListed()) {
ENTRIES.remove(character.getId());
return;
}
Entry existing = ENTRIES.get(character.getId());
Entry entry = new Entry();
entry.ownerUuid = ownerUuid;
entry.characterId = character.getId();
entry.mailListed = character.isMailListed();
entry.displayPlain = character.getEffectiveDisplayPlain();
entry.displayTab = DisplayIdentityService.resolveDisplayTab(character);
if (character.hasLastLocation()) {
Expand Down Expand Up @@ -213,6 +214,10 @@ public static List<CharacterMailTarget> listMailTargets() {
if (character == null || character.getId() == null) {
continue;
}
if (!character.isMailListed()) {
targets.remove(character.getId());
continue;
}
if (targets.containsKey(character.getId())) {
continue;
}
Expand Down Expand Up @@ -263,7 +268,7 @@ private static void upsertFromJson(UUID ownerUuid, JSONObject json) {
return;
}
}
if (status != Status.ALIVE) {
if (status != Status.ALIVE || "false".equalsIgnoreCase(String.valueOf(json.get("mail-listed")))) {
ENTRIES.remove(characterId);
return;
}
Expand All @@ -279,6 +284,7 @@ private static void upsertFromJson(UUID ownerUuid, JSONObject json) {
Entry entry = new Entry();
entry.ownerUuid = ownerUuid;
entry.characterId = characterId;
entry.mailListed = true;
entry.displayPlain = displayPlain;
entry.displayTab = DisplayIdentityService.colourPlain(displayPlain, colour);
Object locRaw = json.get("last-location");
Expand All @@ -303,6 +309,7 @@ private static Entry fromLive(UUID ownerUuid, RPCharacter character) {
Entry entry = new Entry();
entry.ownerUuid = ownerUuid;
entry.characterId = character.getId();
entry.mailListed = character.isMailListed();
entry.displayPlain = character.getEffectiveDisplayPlain();
entry.displayTab = DisplayIdentityService.resolveDisplayTab(character);
if (character.hasLastLocation()) {
Expand All @@ -316,10 +323,16 @@ private static Entry fromLive(UUID ownerUuid, RPCharacter character) {
}

private static CharacterMailTarget toTarget(Entry entry) {
if (entry == null || entry.characterId == null) {
if (entry == null || entry.characterId == null || !entry.mailListed) {
return null;
}
PlayerData pd = PlayerManager.get(entry.ownerUuid);
if (pd != null) {
RPCharacter character = pd.getCharacterById(entry.characterId);
if (character != null && (character.getStatus() != Status.ALIVE || !character.isMailListed())) {
return null;
}
}
Player owner = resolveOwner(entry.ownerUuid, pd);
boolean liveActive = false;
RPCharacter liveCharacter = null;
Expand Down Expand Up @@ -390,6 +403,8 @@ private static Location toBukkitLocation(Entry entry) {
private static final class Entry {
private UUID ownerUuid;
private String characterId;
// Texture-only cache entries must not become recipients.
private boolean mailListed;
private String displayTab = "";
private String displayPlain = "";
private boolean hasStoredLocation;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ public class RPCharacter {
private String alias;
private String slug;
private boolean hidden;
private boolean mailListed = true;
/** Throwaway character made in-game while dev-characters is on. */
private boolean dev;
/** Per grant-kit id. Empty/missing kit = legacy never claim for that kit. */
Expand Down Expand Up @@ -507,6 +508,14 @@ public void setSlug(String slug) {
this.slug = slug;
}

public boolean isMailListed() {
return mailListed;
}

public void setMailListed(boolean mailListed) {
this.mailListed = mailListed;
}

public boolean isHidden() {
return hidden;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
public class CommandTabCompleter implements TabCompleter {

private static final List<String> PERSONA_SUBCOMMANDS = List.of(
"alias", "namecolour", "gender", "description", "profile", "override", "birthday");
"alias", "namecolour", "gender", "description", "profile", "override", "birthday", "mail");
private static final List<String> CLEAR = List.of("clear");
private static final List<String> OVERRIDE_FIELDS = List.of(
"alias", "tempalias", "gender", "description", "namecolour", "birthday", "playtime");
Expand Down Expand Up @@ -361,6 +361,8 @@ private List<String> completePersona(CommandSender sender, String sub, String[]
}

switch (sub) {
case "mail":
return args.length == 2 ? filter(List.of("on", "off"), args[1]) : Collections.emptyList();
case "alias":
case "description":
if (args.length == 2) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package net.tfminecraft.rpcharacters.command;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;

import java.util.List;

import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;

import net.tfminecraft.rpcharacters.RPCharacters;
import net.tfminecraft.rpcharacters.managers.PlayerManager;
import net.tfminecraft.rpcharacters.objects.PlayerData;
import net.tfminecraft.rpcharacters.objects.RPCharacter;
import net.tfminecraft.rpcharacters.utils.CommandTabCompleter;

class CharacterMailCommandTest {
@Test void togglesOnlyOwnActiveCharacterAndSavesExplicitOnOff() {
Player player = mock(Player.class);
PlayerData data = mock(PlayerData.class);
PlayerManager manager = mock(PlayerManager.class);
RPCharacter character = new RPCharacter(null);
when(data.hasActiveCharacter()).thenReturn(true);
when(data.getActiveCharacter()).thenReturn(character);
try (var players = mockStatic(PlayerManager.class); var rpc = mockStatic(RPCharacters.class)) {
players.when(() -> PlayerManager.get(player)).thenReturn(data);
rpc.when(RPCharacters::getPlayerManager).thenReturn(manager);
assertTrue(CharCommand.isPersonaSubcommand("mail"));
CharCommand.handle(player, "rpcharacter", new String[]{"mail"});
assertFalse(character.isMailListed());
CharCommand.handle(player, "rpcharacter", new String[]{"mail", "ON"});
assertTrue(character.isMailListed());
CharCommand.handle(player, "rpcharacter", new String[]{"mail", "off"});
assertFalse(character.isMailListed());
CharCommand.handle(player, "rpcharacter", new String[]{"mail", "off"});
assertFalse(character.isMailListed());
verify(manager, times(4)).savePlayer(player);
CharCommand.handle(player, "rpcharacter", new String[]{"mail", "invalid"});
CharCommand.handle(player, "rpcharacter", new String[]{"mail", "on", "someone-else"});
assertFalse(character.isMailListed());
when(data.hasActiveCharacter()).thenReturn(false);
CharCommand.handle(player, "rpcharacter", new String[]{"mail", "on"});
verifyNoMoreInteractions(manager);
}
}

@Test void consoleIsRejectedAndCompletionsExposeOnOff() {
CommandSender console = mock(CommandSender.class);
CharCommand.handle(console, "rpcharacter", new String[]{"mail"});
verify(console).sendMessage(contains("Only players"));
Command command = mock(Command.class);
when(command.getName()).thenReturn("rpcharacter");
Player player = mock(Player.class);
CommandTabCompleter completer = new CommandTabCompleter();
assertEquals(List.of("mail"), completer.onTabComplete(player, command, "rpcharacter", new String[]{"mai"}));
assertEquals(List.of("on", "off"), completer.onTabComplete(player, command, "rpcharacter", new String[]{"mail", ""}));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package net.tfminecraft.rpcharacters.mail;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;

import java.lang.reflect.Method;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;

import org.bukkit.Bukkit;
import org.json.simple.JSONObject;
import org.json.simple.parser.JSONParser;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

import net.tfminecraft.rpcharacters.database.Database;
import net.tfminecraft.rpcharacters.enums.Status;
import net.tfminecraft.rpcharacters.managers.PlayerManager;
import net.tfminecraft.rpcharacters.objects.PlayerData;
import net.tfminecraft.rpcharacters.objects.RPCharacter;

class MailRecipientDirectoryTest {
@TempDir Path folder;
private final UUID owner = UUID.randomUUID();
private final String id = UUID.randomUUID().toString();

@AfterEach void cleanup() {
MailRecipientDirectory.remove(id);
}

private void loadDirectory(JSONObject json) throws Exception {
Method load = MailRecipientDirectory.class.getDeclaredMethod("upsertFromJson", UUID.class, JSONObject.class);
load.setAccessible(true);
load.invoke(null, owner, json);
}

@Test void savedOptOutIsRespectedAndWardrobeRefreshCannotReintroduceIt() throws Exception {
JSONObject json = new JSONObject(Map.of("id", id, "name", "Recipient", "status", "ALIVE"));
try (var bukkit = mockStatic(Bukkit.class); var manager = mockStatic(PlayerManager.class)) {
loadDirectory(json);
assertEquals(1, MailRecipientDirectory.listMailTargets().size());
json.put("mail-listed", false);
loadDirectory(json);
MailRecipientDirectory.updateWardrobeTexture(owner, id, "texture", "signature");
assertTrue(MailRecipientDirectory.listMailTargets().isEmpty());
json.put("mail-listed", true);
loadDirectory(json);
assertEquals(1, MailRecipientDirectory.listMailTargets().size());
json.put("status", "DEAD");
loadDirectory(json);
MailRecipientDirectory.updateWardrobeTexture(owner, id, "texture", "signature");
assertTrue(MailRecipientDirectory.listMailTargets().isEmpty());
}
}

@Test void liveOptOutOverridesCachedEntryAndOnlineFallback() {
RPCharacter character = new RPCharacter(null);
character.setId(id);
character.setName("Recipient");
PlayerData data = mock(PlayerData.class);
when(data.getUniqueId()).thenReturn(owner);
when(data.getCharacters(Status.ALIVE)).thenReturn(List.of(character));
when(data.getCharacterById(id)).thenReturn(character);
try (var bukkit = mockStatic(Bukkit.class); var manager = mockStatic(PlayerManager.class)) {
manager.when(PlayerManager::getOnlineData).thenReturn(List.of(data));
manager.when(() -> PlayerManager.get(owner)).thenReturn(data);
MailRecipientDirectory.upsert(owner, character);
assertEquals(1, MailRecipientDirectory.listMailTargets().size());
character.setMailListed(false);
assertTrue(MailRecipientDirectory.listMailTargets().isEmpty());
MailRecipientDirectory.upsert(owner, character);
assertTrue(MailRecipientDirectory.listMailTargets().isEmpty());
character.setMailListed(true);
assertEquals(1, MailRecipientDirectory.listMailTargets().size());
MailRecipientDirectory.upsert(owner, character);
manager.when(PlayerManager::getOnlineData).thenReturn(List.of());
manager.when(() -> PlayerManager.get(owner)).thenReturn(null);
assertEquals(1, MailRecipientDirectory.listMailTargets().size());
}
}

@Test void preferenceRoundTripsThroughCharacterPersistenceAndLegacyDefaultsToListed() throws Exception {
RPCharacter original = new RPCharacter(null);
assertTrue(original.isMailListed());
Method save = Database.class.getDeclaredMethod("savePersonaFields", HashMap.class, RPCharacter.class);
Method load = Database.class.getDeclaredMethod("loadPersonaFields", RPCharacter.class, JSONObject.class);
save.setAccessible(true);
load.setAccessible(true);
Database database = new Database();
// saveCharacter initializes the serializer from an empty character document.
var jsonField = Database.class.getDeclaredField("json");
jsonField.setAccessible(true);
jsonField.set(database, new JSONObject());
for (boolean listed : List.of(false, true)) {
original.setMailListed(listed);
HashMap<String, Object> fields = new HashMap<>();
save.invoke(database, fields, original);
RPCharacter restored = new RPCharacter(null);
Path file = folder.resolve("character.json");
assertTrue(database.save(file.toFile(), fields));
JSONObject persisted;
try (var reader = Files.newBufferedReader(file)) {
persisted = (JSONObject) new JSONParser().parse(reader);
}
load.invoke(database, restored, persisted);
assertEquals(listed, restored.isMailListed());
persisted.put("id", id);
persisted.put("name", "Recipient");
try (var bukkit = mockStatic(Bukkit.class); var manager = mockStatic(PlayerManager.class)) {
loadDirectory(persisted);
assertEquals(listed ? 1 : 0, MailRecipientDirectory.listMailTargets().size());
}
}
original.setMailListed(false);
load.invoke(database, original, new JSONObject());
assertTrue(original.isMailListed());
}
}
Loading