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
4 changes: 3 additions & 1 deletion src/main/java/net/tfminecraft/codex/Codex.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import net.tfminecraft.codex.api.CodexAPI;
import net.tfminecraft.codex.character.CharacterJournals;
import net.tfminecraft.codex.character.CharacterStore;
import net.tfminecraft.codex.character.LegacyRecipient;
import net.tfminecraft.codex.api.ExpansionCodex;
import net.tfminecraft.codex.commands.MainCommand;
import net.tfminecraft.codex.config.ConfigsManager;
Expand Down Expand Up @@ -62,7 +63,8 @@ public void onEnable(){
return;
}

characterJournals = new CharacterJournals(playerDataManager, new CharacterStore(getDataFolder().toPath()), getLogger());
characterJournals = new CharacterJournals(playerDataManager, new CharacterStore(getDataFolder().toPath()),
new LegacyRecipient(getDataFolder().getParentFile().toPath()), getLogger());
registerEvents();
registerCommands();
characterJournals.start();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.logging.Level;
import java.util.logging.Logger;
Expand Down Expand Up @@ -35,13 +36,15 @@ public final class CharacterJournals implements Listener {
private final PlayerDataManager players;
private final CharacterStore store;
private final Logger logger;
private final LegacyRecipient recipients;
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, Logger logger) {
public CharacterJournals(PlayerDataManager players, CharacterStore store, LegacyRecipient recipients, Logger logger) {
this.players = players;
this.store = store;
this.recipients = recipients;
this.logger = logger;
}

Expand Down Expand Up @@ -73,6 +76,7 @@ public void onQuit(PlayerQuitEvent event) {

void open(Player player, CharacterRef ref) {
close(player, null);
seed(ref.ownerId());
try {
PlayerData pending = unsaved.remove(ref.characterId());
players.addPlayer(pending != null ? pending : store.load(ref.ownerId(), ref.characterId(), player.getName()));
Expand All @@ -83,6 +87,40 @@ void open(Player player, CharacterRef ref) {
}
}

/**
* Once per account: copies its journal from before characters had their own into its most-played
* living character's journal, chosen at the account's first session after the change and pinned
* for good (see {@link LegacyRecipient}). The old file stays as out-of-character reference and
* records the copy; a retry after a failure goes to the same character and adds nothing twice.
*/
void seed(UUID owner) {
if (!store.hasLegacy(owner)) {
return;
}
try {
CharacterStore.LegacyJournal legacy = store.readLegacy(owner);
if (legacy.seededTo() != null) {
return;
}
Optional<UUID> heir = recipients.of(owner, () -> MostPlayed.of(Characters.characters(owner)));
if (heir.isEmpty()) {
return;
}
PlayerData target = unsaved.containsKey(heir.get())
? unsaved.get(heir.get())
: store.load(owner, heir.get(), legacy.name());
int added = target.absorb(legacy.categories());
store.save(target);
unsaved.remove(heir.get());
store.markSeeded(owner, heir.get());
logger.info("Copied " + added + " old Codex discoveries of account " + owner
+ " to its most-played character " + heir.get());
} catch (IOException | RuntimeException ex) {
logger.log(Level.SEVERE, "Could not copy the old Codex of account " + owner
+ " to its most-played character; trying again at its next session", ex);
}
}

/** 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
30 changes: 26 additions & 4 deletions src/main/java/net/tfminecraft/codex/character/CharacterStore.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
/**
* Discovery files. Each character has {@code characters/<character>.yml}. {@code players/<account>.yml}
* holds what an account found before characters had their own; it stays as the account's
* out-of-character reference and no character inherits it. Main thread only.
* out-of-character reference, and is copied once into its most-played character's journal
* ({@code seeded-to} records which). Main thread only.
*/
public final class CharacterStore {
public static final String CHARACTERS = "characters";
Expand All @@ -34,8 +35,11 @@ public CharacterStore(Path dataFolder) {
this.legacy = dataFolder.resolve(LEGACY);
}

/** What an account found before characters had their own journals, with the account name it was saved under. */
public record LegacyJournal(String name, List<PlayerDataCategory> categories) {
/**
* What an account found before characters had their own journals, with the account name it was
* saved under and the character it was copied to, or null if not yet.
*/
public record LegacyJournal(String name, List<PlayerDataCategory> categories, UUID seededTo) {
}

public Path characterFile(UUID characterId) {
Expand Down Expand Up @@ -78,7 +82,25 @@ public boolean hasLegacy(UUID owner) {

public LegacyJournal readLegacy(UUID owner) throws IOException {
YamlConfiguration yaml = read(legacyFile(owner));
return new LegacyJournal(yaml.getString("name"), categories(yaml));
return new LegacyJournal(yaml.getString("name"), categories(yaml), uuid(yaml.getString("seeded-to")));
}

/** Records in the account's old file that its discoveries were copied to the character. */
public void markSeeded(UUID owner, UUID character) throws IOException {
Path file = legacyFile(owner);
YamlConfiguration yaml = read(file);
yaml.set("seeded-to", character.toString());
Path temporary = legacy.resolve(owner + ".yml.tmp");
Files.writeString(temporary, yaml.saveToString(), StandardCharsets.UTF_8);
Files.move(temporary, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
}

private static UUID uuid(String text) {
try {
return text == null ? null : UUID.fromString(text);
} catch (IllegalArgumentException ex) {
return null;
}
}

private Path legacyFile(UUID owner) {
Expand Down
69 changes: 69 additions & 0 deletions src/main/java/net/tfminecraft/codex/character/LegacyRecipient.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package net.tfminecraft.codex.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();
}
}
}
29 changes: 29 additions & 0 deletions src/main/java/net/tfminecraft/codex/character/MostPlayed.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package net.tfminecraft.codex.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());
}
}
28 changes: 28 additions & 0 deletions src/main/java/net/tfminecraft/codex/model/data/PlayerData.java
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,34 @@ public long getMillisActionsExecuted(String category,String discovery){
return playerDataCategory.getMillisActionsExecuted(discovery);
}

/**
* Adds discoveries this character lacks, keeping its own entries. A category counts as completed
* when either side completed it, so completion rewards are not paid twice.
* @return how many discoveries were added
*/
public int absorb(List<PlayerDataCategory> others){
int added = 0;
for(PlayerDataCategory other : others){
PlayerDataCategory own = getCategory(other.getName());
if(own == null){
own = new PlayerDataCategory(other.getName(),false,new ArrayList<>());
categories.add(own);
}
if(other.isCompleted()){
own.setCompleted(true);
}
for(PlayerDataDiscovery discovery : other.getDiscoveries()){
if(!own.hasDiscovery(discovery.getDiscoveryName())){
own.addDiscovery(new PlayerDataDiscovery(discovery.getDiscoveryName(),
discovery.getDiscoverDate(),discovery.getMillisActionsExecuted()));
added++;
}
}
}
modified = true;
return added;
}

public void resetData(){
categories = new ArrayList<>();
modified = true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ public void flush() {
public void close() {
}
});
journals = new CharacterJournals(players, store, logger);
journals = new CharacterJournals(players, store, new LegacyRecipient(folder.resolve("plugins")), logger);
player = mock(Player.class);
when(player.getUniqueId()).thenReturn(owner);
when(player.getName()).thenReturn("Steve");
Expand Down Expand Up @@ -125,6 +125,83 @@ void theAccountJournalIsReferenceOnlyAndStaysInPlace() throws IOException {
assertTrue(store.hasLegacy(owner));
}

@Test
void theMostPlayedCharacterGetsTheOldJournalOnceWhoeverLogsInFirst() throws IOException {
legacy("syrindell");
characters.when(() -> Characters.characters(owner)).thenReturn(Optional.of(List.of(
played(first, "Aldric", 9000), played(second, "Mira", 100))));

journals.onStart(start(second));

assertFalse(players.getPlayer(player).hasDiscovery("poi", "syrindell"));
assertTrue(store.load(owner, first, "Steve").hasDiscovery("poi", "syrindell"));
assertEquals(first, store.readLegacy(owner).seededTo());
assertEquals(List.of("Aldric"), journals.knowledge(player).other("poi", "syrindell").livingNames());
assertLogged(Level.INFO, "Copied 1 old Codex discoveries of account " + owner);

journals.onStart(start(first));
PlayerData aldric = players.getPlayer(player);
assertTrue(aldric.hasDiscovery("poi", "syrindell"));
aldric.resetDiscovery("poi", "syrindell");
journals.onStart(start(second));
assertFalse(store.load(owner, first, "Steve").hasDiscovery("poi", "syrindell"));
}

@Test
void aRetryAfterAFailedMarkerGoesToThePinnedCharacter() throws IOException {
legacy("syrindell");
characters.when(() -> Characters.characters(owner)).thenReturn(Optional.of(List.of(
played(first, "Aldric", 100), played(second, "Mira", 99))));
Files.createDirectories(folder.resolve("players").resolve(owner + ".yml.tmp"));

journals.onStart(start(first));
assertLogged(Level.SEVERE, "Could not copy the old Codex of account " + owner);
assertTrue(store.load(owner, first, "Steve").hasDiscovery("poi", "syrindell"));

Files.delete(folder.resolve("players").resolve(owner + ".yml.tmp"));
characters.when(() -> Characters.characters(owner)).thenReturn(Optional.of(List.of(
played(first, "Aldric", 100), played(second, "Mira", 500))));
journals.onStart(start(second));

assertFalse(players.getPlayer(player).hasDiscovery("poi", "syrindell"));
assertEquals(first, store.readLegacy(owner).seededTo());
}

@Test
void theSeedGoesIntoAJournalStillWaitingToSave() throws IOException {
characters.when(() -> Characters.characters(owner)).thenReturn(Optional.of(List.of(
played(first, "Aldric", 9000), played(second, "Mira", 100))));
journals.onStart(start(first));
players.getPlayer(player).addDiscovery("poi", "vard", "14/10/2026");
Path characters = folder.resolve("characters");
Files.writeString(characters, "not a folder");
journals.onStart(start(second));
assertEquals(1, journals.unsavedJournals().size());
Files.delete(characters);
legacy("syrindell");

journals.onStart(start(second));

PlayerData aldric = store.load(owner, first, "Steve");
assertTrue(aldric.hasDiscovery("poi", "vard"));
assertTrue(aldric.hasDiscovery("poi", "syrindell"));
assertTrue(journals.unsavedJournals().isEmpty());
}

@Test
void noSeedWithoutARosterAndAFailedSeedIsRetried() throws IOException {
legacy("syrindell");
characters.when(() -> Characters.characters(owner)).thenReturn(Optional.empty());
journals.onStart(start(first));
assertNull(store.readLegacy(owner).seededTo());

characters.when(() -> Characters.characters(owner)).thenReturn(Optional.of(List.of(played(first, "Aldric", 10))));
Files.writeString(folder.resolve("characters"), "not a folder");
journals.onStart(start(first));
assertLogged(Level.SEVERE, "Could not copy the old Codex of account " + owner);
assertNull(store.readLegacy(owner).seededTo());
}

@Test
void anUnreadableAccountJournalIsLeftOut() throws IOException {
Files.createDirectories(folder.resolve("players"));
Expand Down Expand Up @@ -283,6 +360,10 @@ void refreshingRereadsOtherCharacters() throws IOException {
assertEquals("Aldric", journals.knowledge(player).characterName());
}

private CharacterInfo played(UUID character, String name, long seconds) {
return new CharacterInfo(new CharacterRef(owner, character), name, CharacterStatus.ALIVE, null, Set.of(), seconds, null);
}

private CharacterInfo info(UUID character, String name, CharacterStatus status) {
return new CharacterInfo(new CharacterRef(owner, character), name, status, null, Set.of(), 0, null);
}
Expand Down
Loading
Loading