From 78b62f483f37f6f878c9d4b2f761621af4bf78ad Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:21:27 +0000 Subject: [PATCH 1/2] Copy each account's old journal to its most-played character once On an account's first session after this change, its journal from before characters had their own is copied into the journal of its most-played living character (else its most-played character), so that character knows those entries in character. Other characters start empty and keep seeing them out of character. The old file stays as reference and gets a seeded-to marker, so the copy happens once; a failed copy is retried at the next session. Co-Authored-By: Claude Opus 5.5 --- .../codex/character/CharacterJournals.java | 35 +++++++++++ .../codex/character/CharacterStore.java | 30 +++++++-- .../codex/character/MostPlayed.java | 29 +++++++++ .../codex/model/data/PlayerData.java | 28 +++++++++ .../character/CharacterJournalsTest.java | 61 +++++++++++++++++++ .../codex/character/CharacterStoreTest.java | 15 +++++ .../codex/character/MostPlayedTest.java | 51 ++++++++++++++++ .../codex/model/data/PlayerDataTest.java | 33 ++++++++++ 8 files changed, 278 insertions(+), 4 deletions(-) create mode 100644 src/main/java/net/tfminecraft/codex/character/MostPlayed.java create mode 100644 src/test/java/net/tfminecraft/codex/character/MostPlayedTest.java create mode 100644 src/test/java/net/tfminecraft/codex/model/data/PlayerDataTest.java diff --git a/src/main/java/net/tfminecraft/codex/character/CharacterJournals.java b/src/main/java/net/tfminecraft/codex/character/CharacterJournals.java index 72cf26c..3a487ef 100644 --- a/src/main/java/net/tfminecraft/codex/character/CharacterJournals.java +++ b/src/main/java/net/tfminecraft/codex/character/CharacterJournals.java @@ -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; @@ -73,6 +74,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())); @@ -83,6 +85,39 @@ 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. The old + * file stays as out-of-character reference and records the copy, so it never happens twice. + */ + void seed(UUID owner) { + if (!store.hasLegacy(owner)) { + return; + } + try { + CharacterStore.LegacyJournal legacy = store.readLegacy(owner); + if (legacy.seededTo() != null) { + return; + } + Optional heir = 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()); diff --git a/src/main/java/net/tfminecraft/codex/character/CharacterStore.java b/src/main/java/net/tfminecraft/codex/character/CharacterStore.java index 44f9443..92f9c2b 100644 --- a/src/main/java/net/tfminecraft/codex/character/CharacterStore.java +++ b/src/main/java/net/tfminecraft/codex/character/CharacterStore.java @@ -20,7 +20,8 @@ /** * Discovery files. Each character has {@code characters/.yml}. {@code players/.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"; @@ -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 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 categories, UUID seededTo) { } public Path characterFile(UUID characterId) { @@ -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) { diff --git a/src/main/java/net/tfminecraft/codex/character/MostPlayed.java b/src/main/java/net/tfminecraft/codex/character/MostPlayed.java new file mode 100644 index 0000000..9056d2f --- /dev/null +++ b/src/main/java/net/tfminecraft/codex/character/MostPlayed.java @@ -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 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 of(Optional> roster) { + List all = roster.orElse(List.of()); + Optional best = all.stream().filter(CharacterInfo::alive).max(PLAYTIME); + if (best.isEmpty()) { + best = all.stream().max(PLAYTIME); + } + return best.map(info -> info.ref().characterId()); + } +} diff --git a/src/main/java/net/tfminecraft/codex/model/data/PlayerData.java b/src/main/java/net/tfminecraft/codex/model/data/PlayerData.java index cbcb3fd..ac499bd 100644 --- a/src/main/java/net/tfminecraft/codex/model/data/PlayerData.java +++ b/src/main/java/net/tfminecraft/codex/model/data/PlayerData.java @@ -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 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; diff --git a/src/test/java/net/tfminecraft/codex/character/CharacterJournalsTest.java b/src/test/java/net/tfminecraft/codex/character/CharacterJournalsTest.java index 37febfe..84a9cef 100644 --- a/src/test/java/net/tfminecraft/codex/character/CharacterJournalsTest.java +++ b/src/test/java/net/tfminecraft/codex/character/CharacterJournalsTest.java @@ -125,6 +125,63 @@ 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 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")); @@ -283,6 +340,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); } diff --git a/src/test/java/net/tfminecraft/codex/character/CharacterStoreTest.java b/src/test/java/net/tfminecraft/codex/character/CharacterStoreTest.java index cba8ee5..023fd70 100644 --- a/src/test/java/net/tfminecraft/codex/character/CharacterStoreTest.java +++ b/src/test/java/net/tfminecraft/codex/character/CharacterStoreTest.java @@ -128,6 +128,21 @@ void legacyJournalIsReadInPlace() throws IOException { assertEquals("Steve", legacy.name()); assertTrue(legacy.categories().get(0).isCompleted()); assertTrue(legacy.categories().get(0).hasDiscovery("die")); + assertNull(legacy.seededTo()); + + store.markSeeded(owner, character); + + CharacterStore.LegacyJournal seeded = store.readLegacy(owner); + assertEquals(character, seeded.seededTo()); + assertTrue(seeded.categories().get(0).hasDiscovery("die")); + assertFalse(Files.exists(folder.resolve("players").resolve(owner + ".yml.tmp"))); + } + + @Test + void aGarbledSeedMarkerCountsAsNotSeeded() throws IOException { + write("players", owner, "seeded-to: nonsense\n"); + + assertNull(store.readLegacy(owner).seededTo()); } @Test diff --git a/src/test/java/net/tfminecraft/codex/character/MostPlayedTest.java b/src/test/java/net/tfminecraft/codex/character/MostPlayedTest.java new file mode 100644 index 0000000..2baeae6 --- /dev/null +++ b/src/test/java/net/tfminecraft/codex/character/MostPlayedTest.java @@ -0,0 +1,51 @@ +package net.tfminecraft.codex.character; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +import net.tfminecraft.tlibs.character.CharacterInfo; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.CharacterStatus; + +class MostPlayedTest { + private final UUID owner = UUID.randomUUID(); + + @Test + void theMostPlayedLivingCharacterWins() { + CharacterInfo dead = info(CharacterStatus.DEAD, 9000, null); + CharacterInfo less = info(CharacterStatus.ALIVE, 100, Instant.parse("2026-10-01T00:00:00Z")); + CharacterInfo tied = info(CharacterStatus.ALIVE, 500, null); + CharacterInfo later = info(CharacterStatus.ALIVE, 500, Instant.parse("2026-10-02T00:00:00Z")); + + assertEquals(Optional.of(id(later)), MostPlayed.of(Optional.of(List.of(dead, less, tied, later)))); + } + + @Test + void anAllDeadAccountFallsBackToItsMostPlayed() { + CharacterInfo less = info(CharacterStatus.DEAD, 10, null); + CharacterInfo more = info(CharacterStatus.MISSING, 20, null); + + assertEquals(Optional.of(id(more)), MostPlayed.of(Optional.of(List.of(less, more)))); + } + + @Test + void noRosterOrNoCharacterMeansNobody() { + assertEquals(Optional.empty(), MostPlayed.of(Optional.empty())); + assertEquals(Optional.empty(), MostPlayed.of(Optional.of(List.of()))); + } + + private CharacterInfo info(CharacterStatus status, long playtime, Instant lastPlayed) { + return new CharacterInfo(new CharacterRef(owner, UUID.randomUUID()), "Name", status, null, Set.of(), playtime, lastPlayed); + } + + private static UUID id(CharacterInfo info) { + return info.ref().characterId(); + } +} diff --git a/src/test/java/net/tfminecraft/codex/model/data/PlayerDataTest.java b/src/test/java/net/tfminecraft/codex/model/data/PlayerDataTest.java new file mode 100644 index 0000000..3fd306e --- /dev/null +++ b/src/test/java/net/tfminecraft/codex/model/data/PlayerDataTest.java @@ -0,0 +1,33 @@ +package net.tfminecraft.codex.model.data; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +class PlayerDataTest { + @Test + void absorbAddsOnlyMissingDiscoveriesAndKeepsCompletion() { + PlayerData own = new PlayerData(UUID.randomUUID(), UUID.randomUUID(), "Steve"); + own.addDiscovery("poi", "vard", "14/10/2026"); + ArrayList poi = new ArrayList<>(List.of( + new PlayerDataDiscovery("vard", "01/09/2026", 5), + new PlayerDataDiscovery("aurea", "02/09/2026", 7))); + ArrayList achievements = new ArrayList<>(List.of(new PlayerDataDiscovery("die", "03/09/2026", 0))); + + int added = own.absorb(List.of(new PlayerDataCategory("poi", false, poi), + new PlayerDataCategory("achievements", true, achievements))); + + assertEquals(2, added); + assertTrue(own.isModified()); + assertEquals("14/10/2026", own.getDiscovery("poi", "vard").getDiscoverDate()); + assertEquals(7, own.getDiscovery("poi", "aurea").getMillisActionsExecuted()); + assertTrue(own.isCategoryCompleted("achievements")); + assertFalse(own.isCategoryCompleted("poi")); + } +} From dc1ac958318343d71f69dc670c9dd9df99e5dbaf Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:30:03 +0000 Subject: [PATCH 2/2] Address review: pin one recipient per account for both plugins The character that receives an account's old data is chosen once and pinned in plugins/TLibs/legacy-recipients/.txt, written atomically and never overwritten while readable. Codex and ConditionalEvents both use the pin, so they always pick the same character, and a retry after a failure goes to that character again. Co-Authored-By: Claude Opus 5.5 --- .../java/net/tfminecraft/codex/Codex.java | 4 +- .../codex/character/CharacterJournals.java | 11 +-- .../codex/character/LegacyRecipient.java | 69 ++++++++++++++++++ .../character/CharacterJournalsTest.java | 22 +++++- .../codex/character/LegacyRecipientTest.java | 71 +++++++++++++++++++ 5 files changed, 171 insertions(+), 6 deletions(-) create mode 100644 src/main/java/net/tfminecraft/codex/character/LegacyRecipient.java create mode 100644 src/test/java/net/tfminecraft/codex/character/LegacyRecipientTest.java diff --git a/src/main/java/net/tfminecraft/codex/Codex.java b/src/main/java/net/tfminecraft/codex/Codex.java index 07df9e2..3e10945 100644 --- a/src/main/java/net/tfminecraft/codex/Codex.java +++ b/src/main/java/net/tfminecraft/codex/Codex.java @@ -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; @@ -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(); diff --git a/src/main/java/net/tfminecraft/codex/character/CharacterJournals.java b/src/main/java/net/tfminecraft/codex/character/CharacterJournals.java index 3a487ef..fba6043 100644 --- a/src/main/java/net/tfminecraft/codex/character/CharacterJournals.java +++ b/src/main/java/net/tfminecraft/codex/character/CharacterJournals.java @@ -36,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 knowledge = new HashMap<>(); /** Journals of characters no longer in play whose last save failed, by character; retried with every save. */ private final Map 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; } @@ -87,8 +89,9 @@ 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. The old - * file stays as out-of-character reference and records the copy, so it never happens twice. + * 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)) { @@ -99,7 +102,7 @@ void seed(UUID owner) { if (legacy.seededTo() != null) { return; } - Optional heir = MostPlayed.of(Characters.characters(owner)); + Optional heir = recipients.of(owner, () -> MostPlayed.of(Characters.characters(owner))); if (heir.isEmpty()) { return; } diff --git a/src/main/java/net/tfminecraft/codex/character/LegacyRecipient.java b/src/main/java/net/tfminecraft/codex/character/LegacyRecipient.java new file mode 100644 index 0000000..8165623 --- /dev/null +++ b/src/main/java/net/tfminecraft/codex/character/LegacyRecipient.java @@ -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/.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 of(UUID owner, Supplier> choose) throws IOException { + Path file = folder.resolve(owner + ".txt"); + Optional pinned = read(file); + if (pinned.isPresent()) { + return pinned; + } + boolean unreadable = Files.exists(file); + Optional 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 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(); + } + } +} diff --git a/src/test/java/net/tfminecraft/codex/character/CharacterJournalsTest.java b/src/test/java/net/tfminecraft/codex/character/CharacterJournalsTest.java index 84a9cef..d23ce55 100644 --- a/src/test/java/net/tfminecraft/codex/character/CharacterJournalsTest.java +++ b/src/test/java/net/tfminecraft/codex/character/CharacterJournalsTest.java @@ -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"); @@ -147,6 +147,26 @@ void theMostPlayedCharacterGetsTheOldJournalOnceWhoeverLogsInFirst() throws IOEx 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( diff --git a/src/test/java/net/tfminecraft/codex/character/LegacyRecipientTest.java b/src/test/java/net/tfminecraft/codex/character/LegacyRecipientTest.java new file mode 100644 index 0000000..c89d315 --- /dev/null +++ b/src/test/java/net/tfminecraft/codex/character/LegacyRecipientTest.java @@ -0,0 +1,71 @@ +package net.tfminecraft.codex.character; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class LegacyRecipientTest { + @TempDir + Path plugins; + + private final UUID owner = UUID.randomUUID(); + private final UUID aldric = UUID.randomUUID(); + private final UUID mira = UUID.randomUUID(); + + @Test + void theFirstChoiceIsPinnedForGood() throws IOException { + LegacyRecipient recipients = new LegacyRecipient(plugins); + + assertEquals(Optional.of(aldric), recipients.of(owner, () -> Optional.of(aldric))); + assertEquals(Optional.of(aldric), recipients.of(owner, () -> Optional.of(mira))); + assertEquals(aldric.toString(), Files.readString(pin())); + assertFalse(Files.exists(pin().resolveSibling(owner + ".txt.tmp"))); + } + + @Test + void nobodyToChooseLeavesNoPin() throws IOException { + assertEquals(Optional.empty(), new LegacyRecipient(plugins).of(owner, Optional::empty)); + assertFalse(Files.exists(pin())); + } + + @Test + void anUnreadablePinIsReplaced() throws IOException { + Files.createDirectories(pin().getParent()); + Files.writeString(pin(), "garbled"); + + assertEquals(Optional.of(mira), new LegacyRecipient(plugins).of(owner, () -> Optional.of(mira))); + assertEquals(mira.toString(), Files.readString(pin())); + } + + @Test + void aPinWrittenMeanwhileWins() throws IOException { + LegacyRecipient recipients = new LegacyRecipient(plugins); + + Optional result = recipients.of(owner, () -> { + try { + Files.createDirectories(pin().getParent()); + Files.writeString(pin(), aldric.toString()); + } catch (IOException ex) { + throw new IllegalStateException(ex); + } + return Optional.of(mira); + }); + + assertEquals(Optional.of(aldric), result); + assertTrue(Files.exists(pin())); + assertFalse(Files.exists(pin().resolveSibling(owner + ".txt.tmp"))); + } + + private Path pin() { + return plugins.resolve("TLibs").resolve("legacy-recipients").resolve(owner + ".txt"); + } +}