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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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());
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} 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");
Expand All @@ -1019,6 +1022,7 @@ private void saveTraitState(HashMap<String, Object> 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()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<Trait> healingTraits = new ArrayList<>();
for (Trait trait : character.getTraits()) {
if (trait.hasDuration()) {
Expand All @@ -65,29 +68,27 @@ private static void processCharacter(Player player, RPCharacter character, long
return;
}

boolean needsRefresh = false;
boolean initialized = false;
List<Trait> 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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
1 change: 1 addition & 0 deletions src/main/resources/injuries.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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());
}
}
Loading