From 98518694d4201dd9326af493c4f88f5ea2a9b280 Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:21:13 +0000 Subject: [PATCH 1/2] fix: heal injuries in real time, including while offline Healing injury durations were a countdown that only ticked for online players, so a 48h injury took 48 hours of playtime. Trait state now stores the wall-clock time the duration runs out; the healing tick just removes expired injuries and refreshes the fading effects. Saved remaining-ms values from before this change start counting from the next load. The save still writes duration-remaining-ms so an older jar can read the data. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 2 +- .../rpcharacters/database/Database.java | 11 +++-- .../injuries/InjuryHealingService.java | 31 ++++++------ .../rpcharacters/objects/RPCharacter.java | 8 ++++ .../objects/TraitInstanceState.java | 26 ++++++++-- src/main/resources/injuries.yml | 1 + .../objects/TraitInstanceStateTest.java | 48 +++++++++++++++++++ 7 files changed, 102 insertions(+), 25 deletions(-) create mode 100644 src/test/java/net/tfminecraft/rpcharacters/objects/TraitInstanceStateTest.java diff --git a/README.md b/README.md index e0dc4df..721505e 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Beyond a name and appearance, characters have traits, professions, injuries, and - **Mail recipient visibility** — `/rpcharacter mail` toggles whether your active character appears in BirdMessenger’s recipient list; `/rpcharacter mail off` hides it and `/rpcharacter mail on` restores it. Characters are listed by default, and the setting persists across logouts and restarts. Already-sent mail still arrives. - **Character focus** — a shared, regenerating per-character resource used by Research and Magic. - **Progression and rolls** — bring professions, attributes, and dice rolls into character gameplay. -- **Injuries and recovery** — represent injuries and prosthetics, with related treatment and progression systems. +- **Injuries and recovery** — represent injuries and prosthetics, with related treatment and progression systems. Healing injuries heal in real time, including while the player is offline. - **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. - **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. 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. diff --git a/src/main/java/net/tfminecraft/rpcharacters/database/Database.java b/src/main/java/net/tfminecraft/rpcharacters/database/Database.java index 25ffc34..32934a6 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/database/Database.java +++ b/src/main/java/net/tfminecraft/rpcharacters/database/Database.java @@ -994,11 +994,11 @@ private void loadTraitState(RPCharacter character, JSONObject characterJson) { if (!(entryObj instanceof JSONObject entry)) { continue; } - if (entry.containsKey("duration-remaining-ms")) { - Object durationValue = entry.get("duration-remaining-ms"); - if (durationValue instanceof Number number) { - character.setDurationRemainingMs(traitId, number.longValue()); - } + if (entry.get("expires-at-ms") instanceof Number expiresAt) { + character.setDurationExpiresAtMs(traitId, expiresAt.longValue()); + } else if (entry.get("duration-remaining-ms") instanceof Number remaining) { + // Saved before durations ran in real time: the remaining time starts counting from now. + character.setDurationRemainingMs(traitId, remaining.longValue()); } if (entry.containsKey("fuel")) { Object fuelValue = entry.get("fuel"); @@ -1019,6 +1019,7 @@ private void saveTraitState(HashMap defaults, RPCharacter charac } JSONObject traitStateJson = new JSONObject(); if (state.hasDuration()) { + traitStateJson.put("expires-at-ms", state.getExpiresAtMs()); traitStateJson.put("duration-remaining-ms", state.getDurationRemainingMs()); } if (state.hasFuel()) { diff --git a/src/main/java/net/tfminecraft/rpcharacters/injuries/InjuryHealingService.java b/src/main/java/net/tfminecraft/rpcharacters/injuries/InjuryHealingService.java index 3efccbf..d21abca 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/injuries/InjuryHealingService.java +++ b/src/main/java/net/tfminecraft/rpcharacters/injuries/InjuryHealingService.java @@ -34,7 +34,6 @@ public void run() { } static void tick() { - long intervalMs = InjuryPoolLoader.getHealingTickIntervalMs(); for (Player player : Bukkit.getOnlinePlayers()) { if (player.isDead() || PermadeathService.isAwaitingPermakillRespawn(player)) { continue; @@ -50,11 +49,15 @@ static void tick() { continue; } - processCharacter(player, character, intervalMs); + processCharacter(player, character); } } - private static void processCharacter(Player player, RPCharacter character, long intervalMs) { + /** + * Durations count down in real time, including while offline, so the tick only removes injuries whose time + * has run out and refreshes the rest, whose effects fade as they heal. + */ + private static void processCharacter(Player player, RPCharacter character) { List healingTraits = new ArrayList<>(); for (Trait trait : character.getTraits()) { if (trait.hasDuration()) { @@ -65,29 +68,27 @@ private static void processCharacter(Player player, RPCharacter character, long return; } - boolean needsRefresh = false; + boolean initialized = false; List toRemove = new ArrayList<>(); - for (Trait trait : healingTraits) { String traitId = trait.getId(); long remaining = character.getDurationRemainingMs(traitId); if (remaining < 0L) { - remaining = trait.getDurationMs(); - character.setDurationRemainingMs(traitId, remaining); + character.setDurationRemainingMs(traitId, trait.getDurationMs()); + initialized = true; + continue; } - - long newRemaining = remaining - intervalMs; - if (newRemaining <= 0L) { + if (remaining == 0L) { toRemove.add(trait); - } else { - character.setDurationRemainingMs(traitId, newRemaining); - needsRefresh = true; } } - if (needsRefresh && toRemove.isEmpty()) { + if (toRemove.isEmpty()) { refreshCharacter(player, character); - RPCharacters.getPlayerManager().savePlayer(player); + if (initialized) { + RPCharacters.getPlayerManager().savePlayer(player); + } + return; } for (Trait trait : toRemove) { diff --git a/src/main/java/net/tfminecraft/rpcharacters/objects/RPCharacter.java b/src/main/java/net/tfminecraft/rpcharacters/objects/RPCharacter.java index 614ffff..c9b4158 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/objects/RPCharacter.java +++ b/src/main/java/net/tfminecraft/rpcharacters/objects/RPCharacter.java @@ -415,6 +415,14 @@ public void setDurationRemainingMs(String traitId, long durationRemainingMs) { .setDurationRemainingMs(Math.max(0L, durationRemainingMs)); } + public void setDurationExpiresAtMs(String traitId, long expiresAtMs) { + if (traitId == null) { + return; + } + traitState.computeIfAbsent(normalizeTraitStateKey(traitId), ignored -> new TraitInstanceState()) + .setExpiresAtMs(Math.max(0L, expiresAtMs)); + } + public double getFuel(String traitId) { TraitInstanceState state = getTraitState(traitId); return state != null && state.hasFuel() ? state.getFuel() : -1D; diff --git a/src/main/java/net/tfminecraft/rpcharacters/objects/TraitInstanceState.java b/src/main/java/net/tfminecraft/rpcharacters/objects/TraitInstanceState.java index c7a5f4d..ea46ae3 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/objects/TraitInstanceState.java +++ b/src/main/java/net/tfminecraft/rpcharacters/objects/TraitInstanceState.java @@ -2,19 +2,37 @@ public final class TraitInstanceState { - private long durationRemainingMs = -1L; + /** Wall-clock time the trait's duration runs out, so it counts down while the player is offline too. */ + private long expiresAtMs = -1L; private double fuel = -1D; public boolean hasDuration() { - return durationRemainingMs >= 0L; + return expiresAtMs >= 0L; + } + + public long getExpiresAtMs() { + return expiresAtMs; + } + + public void setExpiresAtMs(long expiresAtMs) { + this.expiresAtMs = expiresAtMs; } public long getDurationRemainingMs() { - return durationRemainingMs; + return getDurationRemainingMs(System.currentTimeMillis()); + } + + public long getDurationRemainingMs(long nowMs) { + return hasDuration() ? Math.max(0L, expiresAtMs - nowMs) : -1L; } public void setDurationRemainingMs(long durationRemainingMs) { - this.durationRemainingMs = durationRemainingMs; + setDurationRemainingMs(durationRemainingMs, System.currentTimeMillis()); + } + + public void setDurationRemainingMs(long durationRemainingMs, long nowMs) { + long remaining = Math.max(0L, durationRemainingMs); + expiresAtMs = remaining > Long.MAX_VALUE - nowMs ? Long.MAX_VALUE : nowMs + remaining; } public boolean hasFuel() { diff --git a/src/main/resources/injuries.yml b/src/main/resources/injuries.yml index c4469df..036ff88 100644 --- a/src/main/resources/injuries.yml +++ b/src/main/resources/injuries.yml @@ -1,3 +1,4 @@ +# How often healing injuries are checked. Their durations count down in real time, online or offline. healing-tick-interval: 1m injuries: diff --git a/src/test/java/net/tfminecraft/rpcharacters/objects/TraitInstanceStateTest.java b/src/test/java/net/tfminecraft/rpcharacters/objects/TraitInstanceStateTest.java new file mode 100644 index 0000000..0353f95 --- /dev/null +++ b/src/test/java/net/tfminecraft/rpcharacters/objects/TraitInstanceStateTest.java @@ -0,0 +1,48 @@ +package net.tfminecraft.rpcharacters.objects; + +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 org.junit.jupiter.api.Test; + +class TraitInstanceStateTest { + + private static final long HOUR = 3_600_000L; + private static final long NOW = 1_790_000_000_000L; + + @Test + void durationCountsDownInRealTime() { + TraitInstanceState state = new TraitInstanceState(); + state.setDurationRemainingMs(48 * HOUR, NOW); + + assertEquals(NOW + 48 * HOUR, state.getExpiresAtMs()); + assertEquals(48 * HOUR, state.getDurationRemainingMs(NOW)); + assertEquals(24 * HOUR, state.getDurationRemainingMs(NOW + 24 * HOUR)); + } + + @Test + void remainingStopsAtZeroOnceExpired() { + TraitInstanceState state = new TraitInstanceState(); + state.setDurationRemainingMs(HOUR, NOW); + + assertEquals(0L, state.getDurationRemainingMs(NOW + 5 * HOUR)); + assertTrue(state.hasDuration()); + } + + @Test + void noDurationReportsMinusOne() { + TraitInstanceState state = new TraitInstanceState(); + + assertFalse(state.hasDuration()); + assertEquals(-1L, state.getDurationRemainingMs(NOW)); + } + + @Test + void hugeDurationDoesNotOverflow() { + TraitInstanceState state = new TraitInstanceState(); + state.setDurationRemainingMs(Long.MAX_VALUE, NOW); + + assertEquals(Long.MAX_VALUE, state.getExpiresAtMs()); + } +} From ab24942988f552a0674b79944ef4e653f8f5954d Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:28:28 +0000 Subject: [PATCH 2/2] fix: drop expired injuries from inactive characters on load The healing tick only sees each online player's active character, so an inactive character could keep an injury whose time ran out. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../rpcharacters/database/Database.java | 3 ++ .../rpcharacters/objects/RPCharacter.java | 21 ++++++++++ .../objects/ExpiredDurationTraitsTest.java | 42 +++++++++++++++++++ 3 files changed, 66 insertions(+) create mode 100644 src/test/java/net/tfminecraft/rpcharacters/objects/ExpiredDurationTraitsTest.java diff --git a/src/main/java/net/tfminecraft/rpcharacters/database/Database.java b/src/main/java/net/tfminecraft/rpcharacters/database/Database.java index 32934a6..d99b9ac 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/database/Database.java +++ b/src/main/java/net/tfminecraft/rpcharacters/database/Database.java @@ -314,6 +314,9 @@ public void loadCharacters(PlayerData pd) { CharacterEvilRpFields.load(c, json); loadNutritionFields(c, json); c.ensureTraitStateDefaults(); + if (!Boolean.TRUE.equals(c.isActive()) && c.removeExpiredDurationTraits(System.currentTimeMillis())) { + c.update(); + } if (c.getSlug() == null || c.getSlug().isBlank()) { pd.assignSlug(c); } diff --git a/src/main/java/net/tfminecraft/rpcharacters/objects/RPCharacter.java b/src/main/java/net/tfminecraft/rpcharacters/objects/RPCharacter.java index c9b4158..a81f02d 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/objects/RPCharacter.java +++ b/src/main/java/net/tfminecraft/rpcharacters/objects/RPCharacter.java @@ -423,6 +423,27 @@ public void setDurationExpiresAtMs(String traitId, long expiresAtMs) { .setExpiresAtMs(Math.max(0L, expiresAtMs)); } + /** + * Drops duration traits whose time ran out, for characters the healing tick does not see (inactive ones). + * + * @return true when a trait was removed, so the caller can {@link #update()} the character + */ + public boolean removeExpiredDurationTraits(long nowMs) { + boolean removed = false; + for (Trait trait : new ArrayList<>(traits)) { + if (trait == null || !trait.hasDuration()) { + continue; + } + TraitInstanceState state = getTraitState(trait.getId()); + if (state != null && state.hasDuration() && state.getDurationRemainingMs(nowMs) == 0L) { + traits.remove(trait); + removeTraitState(trait.getId()); + removed = true; + } + } + return removed; + } + public double getFuel(String traitId) { TraitInstanceState state = getTraitState(traitId); return state != null && state.hasFuel() ? state.getFuel() : -1D; diff --git a/src/test/java/net/tfminecraft/rpcharacters/objects/ExpiredDurationTraitsTest.java b/src/test/java/net/tfminecraft/rpcharacters/objects/ExpiredDurationTraitsTest.java new file mode 100644 index 0000000..cbbcc3c --- /dev/null +++ b/src/test/java/net/tfminecraft/rpcharacters/objects/ExpiredDurationTraitsTest.java @@ -0,0 +1,42 @@ +package net.tfminecraft.rpcharacters.objects; + +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 static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import net.tfminecraft.rpcharacters.objects.trait.Trait; + +class ExpiredDurationTraitsTest { + + private static final long NOW = 1_790_000_000_000L; + + @Test + void removesOnlyInjuriesWhoseTimeRanOut() { + Trait healed = trait("broken_arm", true); + Trait healing = trait("broken_leg", true); + Trait permanent = trait("one_legged", false); + RPCharacter character = new RPCharacter(null); + character.getTraits().addAll(List.of(healed, healing, permanent)); + character.setDurationExpiresAtMs("broken_arm", NOW - 1L); + character.setDurationExpiresAtMs("broken_leg", NOW + 60_000L); + + assertTrue(character.removeExpiredDurationTraits(NOW)); + + assertEquals(List.of(healing, permanent), character.getTraits()); + assertEquals(-1L, character.getDurationRemainingMs("broken_arm")); + assertFalse(character.removeExpiredDurationTraits(NOW)); + } + + private static Trait trait(String id, boolean hasDuration) { + Trait trait = mock(Trait.class); + when(trait.getId()).thenReturn(id); + when(trait.hasDuration()).thenReturn(hasDuration); + return trait; + } +}