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 @@ -19,6 +19,7 @@ Beyond a name and appearance, characters have traits, professions, injuries, and
- **Consequences and investigation** — support lethal or nonlethal PvP, graves, and discoverable clues left in the world.
- **Nonlethal knockouts** — GSit holds downed players in a crawl pose for the knockout duration, alongside freeze and blindness. The pose uses GSit's API, bypasses command restrictions, respects other plugins' crawl vetoes, and releases only knockout-created crawls on recovery. GSit is optional for the rest of RPCharacters; without it, knockouts retain freeze and blindness only. While downed, a player can only be finished by another player in lethal mode, a mob, `/kill`, the void or the world border: falls, drowning, fire and other damage with no attacker leave them at half a heart, and the fall distance built up by the freeze is cleared when they get up. Knockouts never apply to players in a started SimpleFactions battle, so the battle records the death and routes the respawn.
- **PvP strikes** — after `/pvp start`, the fight lasts 15 minutes unless the player who started it runs `/pvp end`. When it ends, players who have not died see a title that a new RP interaction is needed. Whoever kills or knocks someone out chooses to spare them or give a strike; the third strike kills the character, though a killer can wound or maim instead of killing. The same player must wait 24 hours by default before striking a character again (`strikes.same-target-cooldown-hours` in `pvp.yml`; 0 disables the wait), so at that default they cannot land all three strikes in a day. Lockpicking, robbing, pickpocketing and looting locked graves start a timed evil RP session, during which any strike kills and a death leaves an unlocked grave.
- **Codex rarity** — `%rpcharacters_codex_percent_<category>:<discovery>%` is the nearest whole percent of Codex accounts that have unlocked that entry. `%rpcharacters_codex_holders_<category>:<discovery>%` is the count. An unknown category or entry is blank. The figure refreshes about once a minute from Codex's saved files, using live Codex data for players who are online.
- **Armour takes time** — putting armour on takes a while (by default 1 second for a helmet, 20 for a chestplate, 12 for leggings and 8 for boots). Pieces go on one at a time and the player is slowed until they're done. Taking or dealing damage, sprinting, dying or logging out stops it. `/pvp start` takes off any chestplate, leggings or boots that someone in range put on in the last 3 minutes, and nobody in the fight can put armour on until it ends unless they die. Once recent armour is off, it notes who is still in a chestplate, leggings and boots: they keep their helmet and can take it off and put it back on for the rest of the fight. Anyone else also loses any helmet, mask or head they put on in the last 3 minutes, and can't put one on until the fight ends. This is decided when the fight is called and doesn't change during it. The times are set under `armour` in `pvp.yml`. Masks, heads and elytras go on at once.

## Beyond the game
Expand Down
3 changes: 3 additions & 0 deletions src/main/java/net/tfminecraft/rpcharacters/RPCharacters.java
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@
import net.tfminecraft.rpcharacters.chat.ChatRecipientResolverRegistry;
import net.tfminecraft.rpcharacters.factions.RealmChatDependencyListener;
import net.tfminecraft.rpcharacters.roll.RollManager;
import net.tfminecraft.rpcharacters.placeholder.CodexPopulationService;
import net.tfminecraft.rpcharacters.placeholder.RpCharactersExpansion;
import net.tfminecraft.rpcharacters.speechbubble.SpeechBubbleListener;
import net.tfminecraft.rpcharacters.speechbubble.SpeechBubbleManager;
Expand Down Expand Up @@ -281,6 +282,7 @@ public void onEnable() {
}
@Override
public void onDisable() {
CodexPopulationService.stop();
if (focusModule != null) focusModule.shutdown();
ChatRecipientResolverRegistry.unregister(
net.tfminecraft.rpcharacters.party.PartyManager.PARTY_RESOLVER_ID);
Expand Down Expand Up @@ -759,6 +761,7 @@ public static int getFreeAttributePoints(Player player) {
private void registerPlaceholderApi() {
if (Bukkit.getPluginManager().isPluginEnabled("PlaceholderAPI")) {
new RpCharactersExpansion().register();
CodexPopulationService.start(this);
getLogger().info("Registered PlaceholderAPI expansion: rpcharacters");
} else {
getLogger().warning("PlaceholderAPI not found — %rpcharacters_*% placeholders will not work.");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package net.tfminecraft.rpcharacters.placeholder;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.UUID;

import org.bukkit.Bukkit;
import org.bukkit.plugin.Plugin;
import org.bukkit.plugin.PluginManager;

/**
* Online Codex accounts, read from Codex's own player map. Codex drops a player
* from that map on quit, so this is only the live overlay on top of the saved files.
*/
final class CodexLiveReader {
private CodexLiveReader() {}

static Map<UUID, Map<String, Set<String>>> current() throws ReflectiveOperationException {
PluginManager plugins = Bukkit.getPluginManager();
if (plugins == null) {
return Map.of();
}
Plugin plugin = plugins.getPlugin("Codex");
if (plugin == null || !plugin.isEnabled()) {
return Map.of();
}
return read(plugin);
}

static Map<UUID, Map<String, Set<String>>> read(Object plugin) throws ReflectiveOperationException {
Object manager = plugin.getClass().getMethod("getPlayerDataManager").invoke(plugin);
if (manager == null) {
return Map.of();
}
Object raw = manager.getClass().getMethod("getPlayers").invoke(manager);
if (!(raw instanceof Map<?, ?> players)) {
return Map.of();
}
List<Object> values = new ArrayList<>();
for (Object value : players.values()) {
values.add(value);
}
Map<UUID, Map<String, Set<String>>> result = new HashMap<>();
for (Object value : values) {
if (value == null) {
continue;
}
Object uuidRaw = value.getClass().getMethod("getUuid").invoke(value);
if (!(uuidRaw instanceof UUID uuid)) {
continue;
}
result.put(uuid, categoriesOf(value));
}
return result;
}

private static Map<String, Set<String>> categoriesOf(Object player) throws ReflectiveOperationException {
Object raw = player.getClass().getMethod("getCategories").invoke(player);
if (!(raw instanceof Iterable<?> categories)) {
return Map.of();
}
Map<String, Set<String>> byCategory = new HashMap<>();
for (Object category : categories) {
if (category == null) {
continue;
}
Object nameRaw = category.getClass().getMethod("getName").invoke(category);
if (!(nameRaw instanceof String name) || name.isBlank()) {
continue;
}
Object discoveriesRaw = category.getClass().getMethod("getDiscoveries").invoke(category);
if (!(discoveriesRaw instanceof Iterable<?> discoveries)) {
continue;
}
Set<String> ids = new HashSet<>();
for (Object discovery : discoveries) {
if (discovery == null) {
continue;
}
Object idRaw = discovery.getClass().getMethod("getDiscoveryName").invoke(discovery);
if (!(idRaw instanceof String id) || id.isBlank()) {
continue;
}
ids.add(id.trim().toLowerCase(Locale.ROOT));
}
if (!ids.isEmpty()) {
byCategory.put(name.trim().toLowerCase(Locale.ROOT), Set.copyOf(ids));
}
}
return byCategory;
}
}
Loading
Loading