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..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); } @@ -994,11 +997,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 +1022,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..a81f02d 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/objects/RPCharacter.java +++ b/src/main/java/net/tfminecraft/rpcharacters/objects/RPCharacter.java @@ -415,6 +415,35 @@ 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)); + } + + /** + * 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/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/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; + } +} 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()); + } +}