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
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package net.tfminecraft.conditionalevents;

import net.tfminecraft.conditionalevents.character.CharacterTracker;
import net.tfminecraft.conditionalevents.character.LegacyRecipient;
import net.tfminecraft.conditionalevents.model.CEEvent;
import net.tfminecraft.conditionalevents.api.ConditionalEventsAPI;
import net.tfminecraft.conditionalevents.api.ExpansionCE;
import net.tfminecraft.conditionalevents.configs.ConfigsManager;
Expand Down Expand Up @@ -63,7 +65,10 @@ public void onEnable(){
this.interruptEventManager = new InterruptEventManager(this);
this.configsManager = new ConfigsManager(this);
this.configsManager.configure();
this.characterTracker = new CharacterTracker();
this.characterTracker = new CharacterTracker(playerManager, name -> {
CEEvent event = eventsManager.getEvent(name);
return event != null && event.usesCharacterData();
}, new LegacyRecipient(getDataFolder().getParentFile().toPath()), getLogger());

registerEvents();
registerCommands();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
package net.tfminecraft.conditionalevents.character;

import java.io.IOException;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Predicate;
import java.util.logging.Level;
import java.util.logging.Logger;

import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
Expand All @@ -12,17 +16,32 @@
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerQuitEvent;

import net.tfminecraft.conditionalevents.managers.PlayerManager;
import net.tfminecraft.conditionalevents.model.player.PlayerData;
import net.tfminecraft.tlibs.character.CharacterRef;
import net.tfminecraft.tlibs.character.CharacterSessionEndEvent;
import net.tfminecraft.tlibs.character.CharacterSessionStartEvent;
import net.tfminecraft.tlibs.character.Characters;

/**
* Which character each player is playing, readable from any thread, since events such as chat run
* off the main thread.
* off the main thread. On an account's first session it also gives the account's old data for
* per-character events to its most-played character, matching what Codex does with old journals.
*/
public final class CharacterTracker implements Listener {
private final Map<UUID, UUID> current = new ConcurrentHashMap<>();
private final PlayerManager players;
private final Predicate<String> perCharacter;
private final LegacyRecipient recipients;
private final Logger logger;

public CharacterTracker(PlayerManager players, Predicate<String> perCharacter, LegacyRecipient recipients,
Logger logger) {
this.players = players;
this.perCharacter = perCharacter;
this.recipients = recipients;
this.logger = logger;
}

/** The character the player is playing, or empty between characters. */
public Optional<UUID> character(UUID account) {
Expand Down Expand Up @@ -53,7 +72,33 @@ public void onQuit(PlayerQuitEvent event) {
}

private void begin(CharacterRef ref) {
seed(ref.ownerId());
current.put(ref.ownerId(), ref.characterId());
}

/**
* Once per account, main thread only, to the character pinned for the account (see
* {@link LegacyRecipient}). Waits for a later session if no character can be chosen yet.
*/
void seed(UUID owner) {
PlayerData data = players.getPlayerByUUID(owner);
if (data == null || data.getSeededTo() != null) {
return;
}
Optional<UUID> heir;
try {
heir = recipients.of(owner, () -> MostPlayed.of(Characters.characters(owner)));
} catch (IOException | RuntimeException ex) {
logger.log(Level.SEVERE, "Could not pin the most-played character of account " + owner
+ "; trying again at its next session", ex);
return;
}
heir.ifPresent(character -> {
int moved = data.seed(character, perCharacter);
if (moved > 0) {
logger.info("Gave " + moved + " old per-character event records of account " + owner
+ " to its most-played character " + character);
}
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package net.tfminecraft.conditionalevents.character;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Optional;
import java.util.UUID;
import java.util.function.Supplier;

/**
* The one character that receives an account's data from before characters had their own, pinned
* in {@code plugins/TLibs/legacy-recipients/<account>.txt}. Codex and ConditionalEvents both read
* this file, so they pick the same character, and retries never pick another. Main thread only.
*/
public final class LegacyRecipient {
static final String FOLDER = "TLibs/legacy-recipients";

private final Path folder;

public LegacyRecipient(Path pluginsFolder) {
this.folder = pluginsFolder.resolve(FOLDER);
}

/**
* The pinned character; if none is pinned yet, pins and returns the chosen one. Empty when none
* is pinned and nobody can be chosen yet.
*/
public Optional<UUID> of(UUID owner, Supplier<Optional<UUID>> choose) throws IOException {
Path file = folder.resolve(owner + ".txt");
Optional<UUID> pinned = read(file);
if (pinned.isPresent()) {
return pinned;
}
boolean unreadable = Files.exists(file);
Optional<UUID> chosen = choose.get();
if (chosen.isEmpty()) {
return chosen;
}
Files.createDirectories(folder);
Path temporary = folder.resolve(owner + ".txt.tmp");
Files.writeString(temporary, chosen.get().toString(), StandardCharsets.UTF_8);
try {
if (unreadable) {
Files.move(temporary, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
} else {
// Without REPLACE_EXISTING the move refuses a pin written meanwhile.
Files.move(temporary, file);
}
} catch (FileAlreadyExistsException pinnedMeanwhile) {
Files.deleteIfExists(temporary);
return read(file).or(() -> chosen);
}
return chosen;
}

private static Optional<UUID> read(Path file) throws IOException {
if (!Files.isRegularFile(file)) {
return Optional.empty();
}
try {
return Optional.of(UUID.fromString(Files.readString(file, StandardCharsets.UTF_8).trim()));
} catch (IllegalArgumentException unreadable) {
return Optional.empty();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package net.tfminecraft.conditionalevents.character;

import java.time.Instant;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import java.util.UUID;

import net.tfminecraft.tlibs.character.CharacterInfo;

/** The account's most-played living character, else its most-played character. */
final class MostPlayed {
private static final Comparator<CharacterInfo> PLAYTIME = Comparator
.comparingLong(CharacterInfo::playtimeSeconds)
.thenComparing(info -> info.lastPlayedAt() == null ? Instant.MIN : info.lastPlayedAt());

private MostPlayed() {
}

/** Empty when the roster is unknown or the account has no character. */
static Optional<UUID> of(Optional<List<CharacterInfo>> roster) {
List<CharacterInfo> all = roster.orElse(List.of());
Optional<CharacterInfo> best = all.stream().filter(CharacterInfo::alive).max(PLAYTIME);
if (best.isEmpty()) {
best = all.stream().max(PLAYTIME);
}
return best.map(info -> info.ref().characterId());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ public class MainConfigManager {
private boolean experimentalVariableReplacement;
private boolean itemMetaVariableEnabled;
private boolean useMiniMessage;
private boolean retainPlayerDataUntilRestart;
private ArrayList<ToConditionGroup> toConditionGroups;
public MainConfigManager(ConditionalEvents plugin){
this.plugin = plugin;
Expand All @@ -38,7 +37,6 @@ public void configure(){
experimentalVariableReplacement = config.getBoolean("Config.experimental.variable_replacement");
itemMetaVariableEnabled = config.getBoolean("Config.item_meta_variable_enabled");
useMiniMessage = config.getBoolean("Config.use_minimessage");
retainPlayerDataUntilRestart = config.getBoolean("Config.retain_player_data_until_restart");
toConditionGroups = new ArrayList<>();
String path = "Config.to_condition_groups";
if(config.contains(path)){
Expand Down Expand Up @@ -85,8 +83,7 @@ public void checkMessagesUpdate(){
Path pathConfig = Paths.get(configFile.getFullPath());
try{
String text = new String(Files.readAllBytes(pathConfig));
if(!text.contains("retain_player_data_until_restart:")){
getConfig().set("Config.retain_player_data_until_restart",true);
if(!text.contains("playerHasNotPlayedRecentlyOrNoData:")){
getConfig().set("Messages.playerHasNotPlayedRecentlyOrNoData","&cThat player has not played recently or doesn't have any data.");
saveConfig();
}
Expand Down Expand Up @@ -208,7 +205,4 @@ public boolean isUseMiniMessage() {
return useMiniMessage;
}

public boolean isRetainPlayerDataUntilRestart() {
return retainPlayerDataUntilRestart;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,20 @@
import net.tfminecraft.conditionalevents.model.player.GenericCallback;
import net.tfminecraft.conditionalevents.model.player.PlayerData;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.scheduler.BukkitRunnable;

import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.logging.Level;

public class PlayersConfigsManager extends DataFolderConfigManager{

Expand Down Expand Up @@ -65,6 +73,14 @@ public void run() {
static PlayerData read(UUID uuid, ConfigurationSection config){
PlayerData playerData = new PlayerData(uuid,config.getString("name"));
playerData.setEventData(readEvents(config.getConfigurationSection("events")));
String seeded = config.getString("seeded-to");
if(seeded != null){
try{
playerData.setSeededTo(UUID.fromString(seeded));
}catch(IllegalArgumentException notCharacter){
// Not a character ID: treat as not seeded.
}
}
ConfigurationSection characters = config.getConfigurationSection("characters");
if(characters != null){
for(String key : characters.getKeys(false)){
Expand Down Expand Up @@ -92,6 +108,7 @@ static void write(PlayerData.Snapshot player, ConfigurationSection players){
players.set("name", player.name());
players.set("events", null);
players.set("characters", null);
players.set("seeded-to", player.seededTo() == null ? null : player.seededTo().toString());
writeEvents(players, "events", player.account());
for(Map.Entry<UUID, List<EventData>> character : player.characters().entrySet()){
writeEvents(players, "characters."+character.getKey()+".events", character.getValue());
Expand All @@ -106,16 +123,35 @@ private static void writeEvents(ConfigurationSection players, String path, List<
}

/** Writes a snapshot; changes made meanwhile keep the data marked modified for the next save. */
public void saveConfig(PlayerData player){
/**
* Writes a snapshot through a temporary file. The data is marked clean only when the write
* succeeded and nothing changed meanwhile; a failure is logged and the data stays modified,
* so the next save tries again.
* @return whether the file was written
*/
public boolean saveConfig(PlayerData player){
synchronized(player.saveLock()){
PlayerData.Snapshot snapshot = player.snapshot();
CommonConfig playerConfig = getConfigFile(player.getUuid().toString()+".yml",true);
write(snapshot, playerConfig.getConfig());
playerConfig.saveConfig();
Path file = plugin.getDataFolder().toPath().resolve(folderName).resolve(player.getUuid()+".yml");
try{
writeAtomically(file, playerConfig.getConfig().saveToString());
}catch(IOException e){
plugin.getLogger().log(Level.SEVERE, "Could not save ConditionalEvents data of "+player.getUuid()+"; retrying with the next save", e);
return false;
}
player.markSaved(snapshot.revision());
return true;
}
}

static void writeAtomically(Path file, String text) throws IOException{
Path temporary = file.resolveSibling(file.getFileName()+".tmp");
Files.writeString(temporary, text, StandardCharsets.UTF_8);
Files.move(temporary, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
}

@Override
public void saveConfigs() {
Map<UUID, PlayerData> players = plugin.getPlayerManager().getPlayers();
Expand All @@ -127,11 +163,42 @@ public void saveConfigs() {
}
}

private final Object filesLock = new Object();

/** Held while loading an account into memory and while resetting the files of accounts not in memory. */
public Object filesLock(){
return filesLock;
}

/**
* Resets the files of accounts not in memory, atomically. Accounts in memory are skipped here:
* they are reset in memory and saved as usual, so a reset never writes over a newer save.
*/
public void resetDataForAllPlayers(String eventName){
ArrayList<CommonConfig> configs = getConfigs();
for(CommonConfig commonConfig : configs) {
if(reset(commonConfig.getConfig(), eventName)){
commonConfig.saveConfig();
Path folder = plugin.getDataFolder().toPath().resolve(folderName);
synchronized(filesLock){
File[] files = folder.toFile().listFiles((dir, name) -> name.endsWith(".yml"));
if(files == null){
return;
}
for(File file : files){
UUID uuid;
try{
uuid = UUID.fromString(file.getName().substring(0, file.getName().length()-4));
}catch(IllegalArgumentException notAccount){
continue;
}
if(plugin.getPlayerManager().getPlayerByUUID(uuid) != null){
continue;
}
YamlConfiguration yaml = YamlConfiguration.loadConfiguration(file);
if(reset(yaml, eventName)){
try{
writeAtomically(file.toPath(), yaml.saveToString());
}catch(IOException e){
plugin.getLogger().log(Level.SEVERE, "Could not reset ConditionalEvents data of "+uuid, e);
}
}
}
}
}
Expand Down
Loading
Loading