diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/usage/UsageRecordResult.java b/src/main/java/dev/hylfrd/farmhelper/feature/usage/UsageRecordResult.java new file mode 100644 index 00000000..3c43392c --- /dev/null +++ b/src/main/java/dev/hylfrd/farmhelper/feature/usage/UsageRecordResult.java @@ -0,0 +1,20 @@ +package dev.hylfrd.farmhelper.feature.usage; + +/** Observable outcome of one usage lifecycle transition or tick. */ +public enum UsageRecordResult { + STARTED, + RESUMED, + PAUSED, + STOPPED, + COUNTED, + IDLE, + NO_TIME, + ALREADY_ACTIVE, + ALREADY_PAUSED, + GAP_REJECTED, + CLOCK_REGRESSION, + INVALID_TIME, + INVALID_EVENT, + OVERFLOW, + INVALID +} diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/usage/UsageSnapshot.java b/src/main/java/dev/hylfrd/farmhelper/feature/usage/UsageSnapshot.java new file mode 100644 index 00000000..f109ba3a --- /dev/null +++ b/src/main/java/dev/hylfrd/farmhelper/feature/usage/UsageSnapshot.java @@ -0,0 +1,96 @@ +package dev.hylfrd.farmhelper.feature.usage; + +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +/** Immutable local usage view captured at one explicit monotonic reading. */ +public record UsageSnapshot( + UsageState state, + Status status, + long sessionNanos, + long last24HoursNanos, + long last7DaysNanos, + long last30DaysNanos, + long lifetimeNanos, + int retainedEventCount, + boolean eventHistoryLimited +) { + public enum Status { + VALID, + INVALID_TIME, + CLOCK_REGRESSION, + OVERFLOW + } + + public static final UsageSnapshot ZERO = new UsageSnapshot( + UsageState.STOPPED, + Status.VALID, + 0L, + 0L, + 0L, + 0L, + 0L, + 0, + false); + + public UsageSnapshot { + Objects.requireNonNull(state, "state"); + Objects.requireNonNull(status, "status"); + if (sessionNanos < 0L + || last24HoursNanos < 0L + || last7DaysNanos < 0L + || last30DaysNanos < 0L + || lifetimeNanos < 0L) { + throw new IllegalArgumentException("usage durations must be non-negative"); + } + if (retainedEventCount < 0) { + throw new IllegalArgumentException("retainedEventCount must be non-negative"); + } + if (state == UsageState.INVALID && status == Status.VALID) { + throw new IllegalArgumentException("invalid state requires an invalid status"); + } + } + + public static UsageSnapshot invalid(UsageState state, Status status) { + if (status == Status.VALID) { + throw new IllegalArgumentException("invalid snapshot requires an invalid status"); + } + return new UsageSnapshot(state, status, 0L, 0L, 0L, 0L, 0L, 0, false); + } + + public boolean valid() { + return state != UsageState.INVALID && status == Status.VALID; + } + + public long todayNanos() { + return last24HoursNanos; + } + + public long totalNanos() { + return lifetimeNanos; + } + + public long sessionMillis() { + return nanosToMillis(sessionNanos); + } + + public long todayMillis() { + return nanosToMillis(last24HoursNanos); + } + + public long last7DaysMillis() { + return nanosToMillis(last7DaysNanos); + } + + public long last30DaysMillis() { + return nanosToMillis(last30DaysNanos); + } + + public long totalMillis() { + return nanosToMillis(lifetimeNanos); + } + + private static long nanosToMillis(long nanos) { + return nanos / TimeUnit.MILLISECONDS.toNanos(1L); + } +} diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/usage/UsageState.java b/src/main/java/dev/hylfrd/farmhelper/feature/usage/UsageState.java new file mode 100644 index 00000000..346be769 --- /dev/null +++ b/src/main/java/dev/hylfrd/farmhelper/feature/usage/UsageState.java @@ -0,0 +1,9 @@ +package dev.hylfrd.farmhelper.feature.usage; + +/** Lifecycle state of the local usage accounting domain. */ +public enum UsageState { + STOPPED, + RUNNING, + PAUSED, + INVALID +} diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/usage/UsageStatsTracker.java b/src/main/java/dev/hylfrd/farmhelper/feature/usage/UsageStatsTracker.java new file mode 100644 index 00000000..54555887 --- /dev/null +++ b/src/main/java/dev/hylfrd/farmhelper/feature/usage/UsageStatsTracker.java @@ -0,0 +1,317 @@ +package dev.hylfrd.farmhelper.feature.usage; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.concurrent.TimeUnit; + +/** + * Pure, client-thread usage accounting for explicit macro-active tick evidence. + * + *
Every accepted active interval is retained in a bounded journal for rolling views. Lifetime + * and current-session totals are aggregate counters, so journal eviction does not change them. + * A journal-capacity flag makes any incomplete rolling view visible to callers.
+ */ +public final class UsageStatsTracker { + public static final long DAY_NANOS = TimeUnit.DAYS.toNanos(1L); + public static final long LAST_24_HOURS_NANOS = DAY_NANOS; + public static final long LAST_7_DAYS_NANOS = TimeUnit.DAYS.toNanos(7L); + public static final long LAST_30_DAYS_NANOS = TimeUnit.DAYS.toNanos(30L); + public static final long DEFAULT_MAX_OBSERVATION_GAP_NANOS = TimeUnit.SECONDS.toNanos(5L); + public static final int DEFAULT_MAX_RETAINED_EVENTS = 4_096; + + private static final long MAX_WINDOW_NANOS = LAST_30_DAYS_NANOS; + private static final long UNSET = Long.MIN_VALUE; + + private final long maxObservationGapNanos; + private final int maxRetainedEvents; + private final DequeUpstream anchors are fixed release {@code eacb323fbde3eff94d4f2ee7baacb059d84b8e3a}, the + * tracker introduction in {@code 66d58c1499f755d270d59ccea1de926b8119dd3e}, and the usage HUD + * behavior finalized through {@code 2f0143c5a717cc178ba9d87ec58f448e368496a}. The applicable + * local behavior is macro-active elapsed time with 24-hour, 7-day, 30-day, and lifetime views. + * This package independently expresses that behavior through explicit monotonic tick evidence.
+ * + *The upstream {@code UsageStatsTracker} also depended on {@code BanInfoWS}, wall-clock dates, + * gzip JSON files named with a player UUID, and fields such as {@code id}, {@code modVersion}, + * {@code fastBreak}, and {@code timestamp}. Those identity-bearing persistence details are + * intentionally removed. The deletion authority is the upstream-parity matrix's BanInfoWS and + * analytics-removal entries: no BanInfoWS, analytics upload, WebSocket transport, remote command, + * telemetry, settings, or client wiring is part of this domain.
+ */ +package dev.hylfrd.farmhelper.feature.usage; diff --git a/src/test/java/dev/hylfrd/farmhelper/feature/usage/UsageStatsTrackerTest.java b/src/test/java/dev/hylfrd/farmhelper/feature/usage/UsageStatsTrackerTest.java new file mode 100644 index 00000000..947e7032 --- /dev/null +++ b/src/test/java/dev/hylfrd/farmhelper/feature/usage/UsageStatsTrackerTest.java @@ -0,0 +1,166 @@ +package dev.hylfrd.farmhelper.feature.usage; + +import org.junit.jupiter.api.Test; + +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class UsageStatsTrackerTest { + private static final long SECOND = TimeUnit.SECONDS.toNanos(1L); + + @Test + void countsOnlyExplicitActiveIntervalsAndExposesStableTotals() { + UsageStatsTracker tracker = new UsageStatsTracker(5L * SECOND, 16); + + assertEquals(UsageRecordResult.STARTED, tracker.start(0L)); + assertEquals(UsageRecordResult.NO_TIME, tracker.record(UsageTick.active(0L))); + assertEquals(UsageRecordResult.COUNTED, tracker.record(UsageTick.active(SECOND))); + assertEquals(UsageRecordResult.IDLE, tracker.record(UsageTick.idle(2L * SECOND))); + assertEquals(UsageRecordResult.COUNTED, tracker.record(true, 3L * SECOND)); + + UsageSnapshot snapshot = tracker.snapshot(3L * SECOND); + assertEquals(UsageState.RUNNING, snapshot.state()); + assertEquals(UsageSnapshot.Status.VALID, snapshot.status()); + assertEquals(2L * SECOND, snapshot.sessionNanos()); + assertEquals(2L * SECOND, snapshot.last24HoursNanos()); + assertEquals(2L * SECOND, snapshot.last7DaysNanos()); + assertEquals(2L * SECOND, snapshot.last30DaysNanos()); + assertEquals(2L * SECOND, snapshot.lifetimeNanos()); + assertEquals(2, snapshot.retainedEventCount()); + assertFalse(snapshot.eventHistoryLimited()); + assertEquals(2_000L, snapshot.totalMillis()); + assertEquals(snapshot, tracker.snapshot(3L * SECOND)); + } + + @Test + void rejectsOversizedActiveGapsWithoutAddingUnknownTime() { + UsageStatsTracker tracker = new UsageStatsTracker(5L * SECOND, 16); + tracker.start(0L); + + assertEquals(UsageRecordResult.COUNTED, tracker.record(true, SECOND)); + assertEquals(UsageRecordResult.GAP_REJECTED, tracker.record(true, 6L * SECOND)); + assertEquals(UsageRecordResult.COUNTED, tracker.record(true, 7L * SECOND)); + assertEquals(UsageRecordResult.GAP_REJECTED, tracker.record(true, 13L * SECOND)); + assertEquals(UsageRecordResult.COUNTED, tracker.record(true, 14L * SECOND)); + + UsageSnapshot snapshot = tracker.snapshot(14L * SECOND); + assertEquals(3L * SECOND, snapshot.sessionNanos()); + assertEquals(3L * SECOND, snapshot.lifetimeNanos()); + assertEquals(3, snapshot.retainedEventCount()); + } + + @Test + void pauseAndResumeExcludeTheExplicitPausedInterval() { + UsageStatsTracker tracker = new UsageStatsTracker(5L * SECOND, 16); + tracker.start(0L); + tracker.record(true, SECOND); + + assertEquals(UsageRecordResult.PAUSED, tracker.pause(2L * SECOND)); + assertEquals(UsageState.PAUSED, tracker.state()); + assertEquals(UsageRecordResult.PAUSED, tracker.record(true, 100L * SECOND)); + assertEquals(UsageRecordResult.RESUMED, tracker.resume(103L * SECOND)); + assertEquals(UsageRecordResult.COUNTED, tracker.record(true, 104L * SECOND)); + + UsageSnapshot snapshot = tracker.snapshot(104L * SECOND); + assertEquals(3L * SECOND, snapshot.sessionNanos()); + assertEquals(3L * SECOND, snapshot.lifetimeNanos()); + assertEquals(3, snapshot.retainedEventCount()); + } + + @Test + void rollingWindowsRolloverFromMonotonicEvidence() { + long day = UsageStatsTracker.DAY_NANOS; + UsageStatsTracker tracker = new UsageStatsTracker(40L * day, 16); + tracker.start(0L); + + tracker.record(true, SECOND); + tracker.record(false, 2L * SECOND); + tracker.record(false, 8L * day + 2L * SECOND); + tracker.record(true, 8L * day + 3L * SECOND); + + UsageSnapshot snapshot = tracker.snapshot(8L * day + 3L * SECOND); + assertEquals(2L * SECOND, snapshot.lifetimeNanos()); + assertEquals(SECOND, snapshot.last24HoursNanos()); + assertEquals(SECOND, snapshot.last7DaysNanos()); + assertEquals(2L * SECOND, snapshot.last30DaysNanos()); + assertEquals(2, snapshot.retainedEventCount()); + } + + @Test + void eventCapacityIsObservableUntilEvictedEvidenceExpires() { + UsageStatsTracker tracker = new UsageStatsTracker(10L * SECOND, 2); + tracker.start(0L); + tracker.record(true, SECOND); + tracker.record(false, 2L * SECOND); + tracker.record(true, 3L * SECOND); + tracker.record(false, 4L * SECOND); + tracker.record(true, 5L * SECOND); + + UsageSnapshot limited = tracker.snapshot(5L * SECOND); + assertEquals(3L * SECOND, limited.lifetimeNanos()); + assertEquals(2L * SECOND, limited.last24HoursNanos()); + assertEquals(2, limited.retainedEventCount()); + assertTrue(limited.eventHistoryLimited()); + + UsageSnapshot rolledOver = tracker.snapshot( + UsageStatsTracker.LAST_30_DAYS_NANOS + 6L * SECOND); + assertEquals(0, rolledOver.retainedEventCount()); + assertFalse(rolledOver.eventHistoryLimited()); + assertEquals(3L * SECOND, rolledOver.lifetimeNanos()); + } + + @Test + void resetClearsHistoryAndStartsANewSession() { + UsageStatsTracker tracker = new UsageStatsTracker(5L * SECOND, 16); + tracker.start(0L); + tracker.record(true, SECOND); + tracker.stop(); + + assertEquals(SECOND, tracker.snapshot(SECOND).lifetimeNanos()); + tracker.reset(); + assertEquals(UsageSnapshot.ZERO, tracker.snapshot(0L)); + assertEquals(UsageRecordResult.STARTED, tracker.start(0L)); + tracker.record(true, SECOND); + assertEquals(SECOND, tracker.snapshot(SECOND).lifetimeNanos()); + } + + @Test + void invalidAndRegressingEvidenceFailsClosedWithoutMutation() { + UsageStatsTracker tracker = new UsageStatsTracker(5L * SECOND, 16); + + assertEquals(UsageRecordResult.INVALID_TIME, tracker.record(true, -1L)); + assertEquals(UsageRecordResult.INVALID_EVENT, tracker.record(null)); + assertEquals(UsageSnapshot.Status.INVALID_TIME, tracker.snapshot(-1L).status()); + assertEquals(UsageRecordResult.STOPPED, tracker.record(true, 0L)); + + tracker.start(0L); + tracker.record(true, SECOND); + assertEquals(UsageRecordResult.CLOCK_REGRESSION, tracker.record(true, 0L)); + UsageSnapshot invalid = tracker.snapshot(0L); + assertEquals(UsageSnapshot.Status.CLOCK_REGRESSION, invalid.status()); + assertFalse(invalid.valid()); + + UsageSnapshot recovered = tracker.snapshot(2L * SECOND); + assertEquals(UsageSnapshot.Status.VALID, recovered.status()); + assertEquals(SECOND, recovered.lifetimeNanos()); + } + + @Test + void invalidBoundsAndTickTimeAreRejected() { + assertThrows(IllegalArgumentException.class, () -> new UsageStatsTracker(0L, 1)); + assertThrows(IllegalArgumentException.class, () -> new UsageStatsTracker(1L, 0)); + assertThrows(IllegalArgumentException.class, () -> new UsageTick(-1L, true)); + + UsageStatsTracker tracker = new UsageStatsTracker(); + assertEquals(UsageRecordResult.INVALID_TIME, tracker.start(-1L)); + assertEquals(UsageRecordResult.STARTED, tracker.start(0L)); + assertEquals(UsageRecordResult.ALREADY_ACTIVE, tracker.start(0L)); + assertEquals(UsageRecordResult.ALREADY_ACTIVE, tracker.resume(0L)); + assertEquals(UsageRecordResult.PAUSED, tracker.pause(0L)); + assertEquals(UsageRecordResult.ALREADY_PAUSED, tracker.pause(0L)); + } +}