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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ Codex gives players a journal of discoveries: places they enter, creatures they
kill and lore that commands unlock. `/codex` opens the journal, and each entry
shows its text once it has been found.

Each roleplay character keeps its own journal, so a new character discovers the
world afresh and earns the discovery rewards again. An entry any of the account's
Each roleplay character keeps its own journal. Exploration belongs to the account:
a character gets the discoveries its account's other characters made, with their
Player EXP, when it starts playing. Research stays with whoever found it, so a new
character researches afresh and earns those rewards again. An entry any of the account's
characters has found stays readable for out-of-character reference, and its hover
text names the living characters who can use it in character. What an account
found before journals were per character stays readable this way, but no
Expand Down
2 changes: 1 addition & 1 deletion src/main/java/net/tfminecraft/codex/Codex.java
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ public void onEnable(){
}

characterJournals = new CharacterJournals(playerDataManager, new CharacterStore(getDataFolder().toPath()),
new LegacyRecipient(getDataFolder().getParentFile().toPath()), getLogger());
new LegacyRecipient(getDataFolder().getParentFile().toPath()), new ExplorationManager(this), getLogger());
registerEvents();
registerCommands();
characterJournals.start();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

import net.tfminecraft.codex.managers.PlayerDataManager;
import net.tfminecraft.codex.model.data.PlayerData;
import net.tfminecraft.codex.model.data.PlayerDataCategory;
import net.tfminecraft.tlibs.character.CharacterInfo;
import net.tfminecraft.tlibs.character.CharacterRef;
import net.tfminecraft.tlibs.character.CharacterSessionEndEvent;
Expand All @@ -30,21 +31,32 @@
* Gives each character its own journal: loaded when a character session starts and saved when it
* ends. Between characters a player has no journal, so nothing can be discovered. Alongside it sits
* what the account knows from its other characters and from before journals were per character, so
* entries stay readable out of character. Main thread only.
* entries stay readable out of character. Shared discoveries (see {@link SharedExploration}) are copied
* into the journal shortly after its session starts. Main thread only.
*/
public final class CharacterJournals implements Listener {
/** Shared discoveries arrive after the character's class and body are in place, and once the screen settles. */
static final long SHARE_DELAY_TICKS = 40L;

private final PlayerDataManager players;
private final CharacterStore store;
private final Logger logger;
private final LegacyRecipient recipients;
private final Exploration exploration;
private final Map<UUID, AccountKnowledge> knowledge = new HashMap<>();
/** Journals of characters no longer in play whose last save failed, by character; retried with every save. */
private final Map<UUID, PlayerData> unsaved = new HashMap<>();

public CharacterJournals(PlayerDataManager players, CharacterStore store, LegacyRecipient recipients, Logger logger) {
/** An account's journal other than the played character's; {@code name} is null for the one from before characters. */
private record Journal(List<PlayerDataCategory> categories, String name, boolean alive) {
}

public CharacterJournals(PlayerDataManager players, CharacterStore store, LegacyRecipient recipients,
Exploration exploration, Logger logger) {
this.players = players;
this.store = store;
this.recipients = recipients;
this.exploration = exploration;
this.logger = logger;
}

Expand Down Expand Up @@ -81,6 +93,9 @@ void open(Player player, CharacterRef ref) {
PlayerData pending = unsaved.remove(ref.characterId());
players.addPlayer(pending != null ? pending : store.load(ref.ownerId(), ref.characterId(), player.getName()));
knowledge.put(ref.ownerId(), knowledgeOf(ref, player.getName()));
if (exploration.settings().enabled()) {
exploration.later(SHARE_DELAY_TICKS, () -> share(player, ref));
}
} catch (IOException ex) {
logger.log(Level.SEVERE, "Codex for character " + ref.characterId() + " of " + player.getName()
+ " could not be read; nothing can be discovered until it is fixed", ex);
Expand Down Expand Up @@ -121,6 +136,31 @@ void seed(UUID owner) {
}
}

/**
* Copies into the played character's journal the shared discoveries its account found elsewhere,
* then runs their rewards that carry over. Only while that character is still in play. The journal
* is saved before the rewards run, so a crash can lose a reward but never pay it twice; a failed save
* keeps the journal in memory, saved with the next save.
*/
void share(Player player, CharacterRef ref) {
PlayerData journal = players.getPlayerByUUID(ref.ownerId());
if (!player.isOnline() || journal == null || !ref.characterId().equals(journal.getCharacterId())) {
return;
}
List<List<PlayerDataCategory>> others = new ArrayList<>();
for (Journal other : otherJournals(ref)) {
others.add(other.categories());
}
List<SharedExploration.Copy> copies = SharedExploration.copy(journal, others, exploration.settings());
if (copies.isEmpty()) {
return;
}
save(journal);
logger.info("Shared " + copies.size() + " Codex discoveries of account " + ref.ownerId()
+ " with its character " + ref.characterId());
exploration.reward(player, copies);
}

/** Saves and unloads the player's journal; with a character ID, only that character's. */
void close(Player player, UUID characterId) {
PlayerData data = players.getPlayerByUUID(player.getUniqueId());
Expand Down Expand Up @@ -186,31 +226,46 @@ private AccountKnowledge knowledgeOf(CharacterRef ref, String fallbackName) {
private AccountKnowledge readKnowledge(CharacterRef ref, String fallbackName) {
String name = fallbackName;
AccountKnowledge.Builder builder = new AccountKnowledge.Builder();
// What the account found before journals were per character: readable, but no character knows it.
// What the account found before journals were per character is readable, but no character knows it.
for (Journal other : otherJournals(ref)) {
builder.add(other.categories(), other.name(), other.alive());
}
for (CharacterInfo info : Characters.characters(ref.ownerId()).orElse(List.of())) {
if (info.ref().characterId().equals(ref.characterId())) {
name = info.name();
}
}
return builder.build(name);
}

/** The account's journal from before characters had their own, then each other character's, alive or dead. */
private List<Journal> otherJournals(CharacterRef ref) {
List<Journal> journals = new ArrayList<>();
if (store.hasLegacy(ref.ownerId())) {
try {
builder.add(store.readLegacy(ref.ownerId()).categories(), null, false);
journals.add(new Journal(store.readLegacy(ref.ownerId()).categories(), null, false));
} catch (IOException ex) {
logger.log(Level.WARNING, "The account Codex of " + ref.ownerId() + " could not be read; "
+ "its old discoveries are left out of its reference", ex);
}
}
List<CharacterInfo> roster = Characters.characters(ref.ownerId()).orElse(List.of());
for (CharacterInfo info : roster) {
for (CharacterInfo info : Characters.characters(ref.ownerId()).orElse(List.of())) {
UUID id = info.ref().characterId();
if (id.equals(ref.characterId())) {
name = info.name();
} else if (unsaved.containsKey(id)) {
builder.add(unsaved.get(id).getCategories(), info.name(), info.alive());
continue;
}
if (unsaved.containsKey(id)) {
journals.add(new Journal(unsaved.get(id).getCategories(), info.name(), info.alive()));
} else if (Files.exists(store.characterFile(id))) {
try {
builder.add(store.load(ref.ownerId(), id, info.name()).getCategories(), info.name(), info.alive());
journals.add(new Journal(store.load(ref.ownerId(), id, info.name()).getCategories(), info.name(),
info.alive()));
} catch (IOException ex) {
logger.log(Level.WARNING, "Codex for character " + id + " could not be read; "
+ "its discoveries are left out of its account's reference", ex);
}
}
}
return builder.build(name);
return journals;
}
}
19 changes: 19 additions & 0 deletions src/main/java/net/tfminecraft/codex/character/Exploration.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package net.tfminecraft.codex.character;

import java.util.List;

import org.bukkit.entity.Player;

/** What sharing discoveries needs from the rest of Codex: its settings, the rewards and the scheduler. */
public interface Exploration {
SharedExploration.Settings settings();

/**
* Runs the copies' reward lines that carry over and those of any category they complete, then
* tells the player what arrived. The copies are already in the player's journal.
*/
void reward(Player player, List<SharedExploration.Copy> copies);

/** Runs the task on the main thread after the given number of ticks. */
void later(long ticks, Runnable task);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package net.tfminecraft.codex.character;

import java.util.ArrayList;
import java.util.List;
import java.util.Set;

import net.tfminecraft.codex.model.data.PlayerData;
import net.tfminecraft.codex.model.data.PlayerDataCategory;
import net.tfminecraft.codex.model.data.PlayerDataDiscovery;

/**
* Exploration belongs to the account: a character gets the discoveries its account's other characters
* and its journal from before characters had their own hold, with the reward lines that carry over
* (Player EXP), once. Entries kept per character, such as research, stay with whoever found them; the
* others keep seeing them out of character and earn them, rewards included, by finding them themselves.
*/
public final class SharedExploration {
/** A reward line starting with {@code from} runs for a copied discovery as {@code to} plus the rest of the line. */
public record Rewrite(String from, String to) {
}

/** A discovery copied into a journal. */
public record Copy(String category, String discovery) {
}

/**
* {@code keptPerCharacter} lists categories and {@code category:discovery} entries that are not shared.
* Reward lines without a matching rewrite are skipped when a discovery is copied.
*/
public record Settings(boolean enabled, Set<String> keptPerCharacter, List<Rewrite> rewards) {
public static final Settings OFF = new Settings(false, Set.of(), List.of());

public Settings {
keptPerCharacter = Set.copyOf(keptPerCharacter);
rewards = List.copyOf(rewards);
}

public boolean shared(String category, String discovery) {
return enabled && !keptPerCharacter.contains(category)
&& !keptPerCharacter.contains(AccountKnowledge.key(category, discovery));
}

/** The line to run for a copied discovery's reward line, or null when it does not carry over. */
public String reward(String line) {
String trimmed = line.trim();
for (Rewrite rewrite : rewards) {
if (trimmed.startsWith(rewrite.from())) {
return rewrite.to() + trimmed.substring(rewrite.from().length());
}
}
return null;
}
}

private SharedExploration() {
}

/**
* Adds to the journal the shared discoveries it lacks from the other journals, keeping the date
* each was first found. Marks the journal modified when anything was added.
* @return the copies, in the order they were added
*/
static List<Copy> copy(PlayerData journal, List<List<PlayerDataCategory>> others, Settings settings) {
List<Copy> copies = new ArrayList<>();
for (List<PlayerDataCategory> other : others) {
for (PlayerDataCategory category : other) {
for (PlayerDataDiscovery discovery : category.getDiscoveries()) {
String id = discovery.getDiscoveryName();
if (settings.shared(category.getName(), id) && !journal.hasDiscovery(category.getName(), id)) {
journal.addDiscovery(category.getName(), id, discovery.getDiscoverDate());
copies.add(new Copy(category.getName(), id));
}
}
}
}
if (!copies.isEmpty()) {
journal.setModified(true);
}
return copies;
}
}
52 changes: 52 additions & 0 deletions src/main/java/net/tfminecraft/codex/config/MainConfigManager.java
Original file line number Diff line number Diff line change
@@ -1,14 +1,28 @@
package net.tfminecraft.codex.config;

import net.tfminecraft.codex.Codex;
import net.tfminecraft.codex.character.SharedExploration;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.file.FileConfiguration;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

public class MainConfigManager {
/** Entries that stay with the character that found them: research, and achievements counting research. */
public static final List<String> KEPT_PER_CHARACTER = List.of("research", "achievements:5_research",
"achievements:10_research", "achievements:25_research", "achievements:all_research",
"achievements:full_codex");
/** Player EXP of a shared discovery goes through RPCharacters, which keeps what a capped class cannot take yet. */
public static final String EXP_FROM = "console_command: mmocore admin exp give %player% main ";
public static final String EXP_TO = "console_command: rpcharacter admin carryexp %player% ";

private Codex plugin;
private CommonConfig configFile;
Expand All @@ -20,6 +34,7 @@ public class MainConfigManager {
private int progressBarPlaceholderAmount;
private boolean useMiniMessage;
private int configVersion;
private SharedExploration.Settings sharedExploration = SharedExploration.Settings.OFF;

public MainConfigManager(Codex plugin){
this.plugin = plugin;
Expand All @@ -37,6 +52,21 @@ public void configure() {
progressBarPlaceholderAmount = config.getInt("progress_bar_placeholder.amount");
useMiniMessage = config.getBoolean("use_minimessage");
configVersion = config.getInt("config_version");
sharedExploration = sharedExploration(config.getConfigurationSection("shared_discoveries"));
}

private static SharedExploration.Settings sharedExploration(ConfigurationSection section) {
if (section == null) {
return SharedExploration.Settings.OFF;
}
List<SharedExploration.Rewrite> rewards = new ArrayList<>();
for (Map<?, ?> reward : section.getMapList("rewards")) {
if (reward.get("from") instanceof String from && !from.isBlank() && reward.get("to") instanceof String to) {
rewards.add(new SharedExploration.Rewrite(from, to));
}
}
return new SharedExploration.Settings(section.getBoolean("enabled"),
new HashSet<>(section.getStringList("keep_per_character")), rewards);
}

public boolean reloadConfig(){
Expand All @@ -55,6 +85,24 @@ public void checkUpdate(){
getConfig().set("use_minimessage",false);
configFile.saveConfig();
}
if(!text.contains("shared_discoveries:")){
FileConfiguration config = getConfig();
config.set("shared_discoveries.enabled",true);
config.set("shared_discoveries.keep_per_character",KEPT_PER_CHARACTER);
Map<String,Object> exp = new LinkedHashMap<>();
exp.put("from",EXP_FROM);
exp.put("to",EXP_TO);
config.set("shared_discoveries.rewards",List.of(exp));
config.setComments("shared_discoveries",List.of(
"Exploration belongs to the account. When a character starts playing, it gets the",
"discoveries its account's other characters (alive or dead) and its journal from before",
"characters made, except the categories and category:discovery entries kept per character.",
"Those stay with whoever found them; the others see them out of character.",
"A copied discovery runs only its reward lines starting with a rewards 'from', as 'to'",
"plus the rest of the line (Player EXP goes through RPCharacters, which keeps what a",
"capped class can't take yet). Its other reward lines are skipped."));
configFile.saveConfig();
}
}catch(IOException e){
e.printStackTrace();
}
Expand Down Expand Up @@ -92,6 +140,10 @@ public int getProgressBarPlaceholderAmount() {
return progressBarPlaceholderAmount;
}

public SharedExploration.Settings getSharedExploration() {
return sharedExploration;
}

public boolean isUseMiniMessage() {
return useMiniMessage;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ public class MessagesConfigManager {
public static final String KNOWN_BY_NOBODY = "&7No character can use this in character.";
public static final String OUT_OF_CHARACTER_ONLY = "&8&o%character% knows this only out of character.";
public static final String NO_CHARACTER_IN_PLAY = "&cChoose a character to open the Codex.";
public static final String OUT_OF_CHARACTER_ADVICE =
"&8&oUse common sense: play %character% as not knowing this until they learn it in character.";
public static final String SHARED_DISCOVERIES =
"&7%amount% Codex entries carried over from your other characters, with their Player EXP.";


private Codex plugin;
Expand Down Expand Up @@ -63,6 +67,14 @@ public void checkUpdates(){
config.set("knownOutOfCharacterOnly",OUT_OF_CHARACTER_ONLY);
configFile.saveConfig();
}
if(!text.contains("knownOutOfCharacterOnlyAdvice:")){
config.set("knownOutOfCharacterOnlyAdvice",OUT_OF_CHARACTER_ADVICE);
configFile.saveConfig();
}
if(!text.contains("sharedDiscoveries:")){
config.set("sharedDiscoveries",SHARED_DISCOVERIES);
configFile.saveConfig();
}
if(!text.contains("noCharacterInPlay:")){
config.set("noCharacterInPlay",NO_CHARACTER_IN_PLAY);
configFile.saveConfig();
Expand Down
Loading
Loading