From 2ad3e31e1b66a8c6fb76ff3c40f347eb1ec1b5bb Mon Sep 17 00:00:00 2001 From: Hylfrd Date: Mon, 31 Aug 2026 11:41:44 +0800 Subject: [PATCH] feat(jacob): add pure threshold detector --- .../feature/jacob/JacobContestEvidence.java | 22 ++ .../feature/jacob/JacobContestIdentity.java | 10 + .../farmhelper/feature/jacob/JacobCrop.java | 59 +++++ .../feature/jacob/JacobEvidenceIssue.java | 14 + .../jacob/JacobEvidenceParseResult.java | 58 +++++ .../feature/jacob/JacobEvidenceParser.java | 239 ++++++++++++++++++ .../feature/jacob/JacobThresholdDetector.java | 126 +++++++++ .../feature/jacob/JacobThresholdResult.java | 14 + .../feature/jacob/JacobThresholdSnapshot.java | 81 ++++++ .../feature/jacob/JacobThresholdStatus.java | 9 + .../feature/jacob/JacobThresholdUpdate.java | 8 + .../feature/jacob/JacobThresholds.java | 43 ++++ .../feature/jacob/package-info.java | 19 ++ .../jacob/JacobThresholdDetectorTest.java | 224 ++++++++++++++++ 14 files changed, 926 insertions(+) create mode 100644 src/main/java/dev/hylfrd/farmhelper/feature/jacob/JacobContestEvidence.java create mode 100644 src/main/java/dev/hylfrd/farmhelper/feature/jacob/JacobContestIdentity.java create mode 100644 src/main/java/dev/hylfrd/farmhelper/feature/jacob/JacobCrop.java create mode 100644 src/main/java/dev/hylfrd/farmhelper/feature/jacob/JacobEvidenceIssue.java create mode 100644 src/main/java/dev/hylfrd/farmhelper/feature/jacob/JacobEvidenceParseResult.java create mode 100644 src/main/java/dev/hylfrd/farmhelper/feature/jacob/JacobEvidenceParser.java create mode 100644 src/main/java/dev/hylfrd/farmhelper/feature/jacob/JacobThresholdDetector.java create mode 100644 src/main/java/dev/hylfrd/farmhelper/feature/jacob/JacobThresholdResult.java create mode 100644 src/main/java/dev/hylfrd/farmhelper/feature/jacob/JacobThresholdSnapshot.java create mode 100644 src/main/java/dev/hylfrd/farmhelper/feature/jacob/JacobThresholdStatus.java create mode 100644 src/main/java/dev/hylfrd/farmhelper/feature/jacob/JacobThresholdUpdate.java create mode 100644 src/main/java/dev/hylfrd/farmhelper/feature/jacob/JacobThresholds.java create mode 100644 src/main/java/dev/hylfrd/farmhelper/feature/jacob/package-info.java create mode 100644 src/test/java/dev/hylfrd/farmhelper/feature/jacob/JacobThresholdDetectorTest.java diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/jacob/JacobContestEvidence.java b/src/main/java/dev/hylfrd/farmhelper/feature/jacob/JacobContestEvidence.java new file mode 100644 index 00000000..8ca18575 --- /dev/null +++ b/src/main/java/dev/hylfrd/farmhelper/feature/jacob/JacobContestEvidence.java @@ -0,0 +1,22 @@ +package dev.hylfrd.farmhelper.feature.jacob; + +import java.util.Objects; + +/** Immutable, normalized evidence for one crop count within one explicit contest identity. */ +public record JacobContestEvidence( + JacobContestIdentity contest, + JacobCrop crop, + long collectedCount, + long sequence +) { + public JacobContestEvidence { + Objects.requireNonNull(contest, "contest"); + Objects.requireNonNull(crop, "crop"); + if (collectedCount < 0L) { + throw new IllegalArgumentException("collected count must be non-negative"); + } + if (sequence < 0L) { + throw new IllegalArgumentException("sequence must be non-negative"); + } + } +} diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/jacob/JacobContestIdentity.java b/src/main/java/dev/hylfrd/farmhelper/feature/jacob/JacobContestIdentity.java new file mode 100644 index 00000000..58f6e517 --- /dev/null +++ b/src/main/java/dev/hylfrd/farmhelper/feature/jacob/JacobContestIdentity.java @@ -0,0 +1,10 @@ +package dev.hylfrd.farmhelper.feature.jacob; + +/** Caller-assigned identity that prevents one contest's count from leaking into another. */ +public record JacobContestIdentity(long value) { + public JacobContestIdentity { + if (value <= 0L) { + throw new IllegalArgumentException("contest identity must be positive"); + } + } +} diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/jacob/JacobCrop.java b/src/main/java/dev/hylfrd/farmhelper/feature/jacob/JacobCrop.java new file mode 100644 index 00000000..1c455d5d --- /dev/null +++ b/src/main/java/dev/hylfrd/farmhelper/feature/jacob/JacobCrop.java @@ -0,0 +1,59 @@ +package dev.hylfrd.farmhelper.feature.jacob; + +import java.util.Locale; +import java.util.Objects; +import java.util.Optional; + +/** Crop names recognized by the fixed upstream Jacob scoreboard vocabulary. */ +public enum JacobCrop { + WHEAT, + CARROT, + POTATO, + NETHER_WART, + SUGAR_CANE, + MUSHROOM, + MELON, + PUMPKIN, + COCOA_BEANS, + CACTUS; + + /** + * Converts one cleaned server line using the same token families as upstream + * {@code GameStateHandler#convertCrop(String)}. + */ + public static Optional fromLine(String line) { + Objects.requireNonNull(line, "line"); + String upper = line.toUpperCase(Locale.ROOT); + if (upper.contains("WHEAT")) { + return Optional.of(WHEAT); + } + if (upper.contains("CARROT")) { + return Optional.of(CARROT); + } + if (upper.contains("POTATO")) { + return Optional.of(POTATO); + } + if (upper.contains("NETHER") || upper.contains("WART")) { + return Optional.of(NETHER_WART); + } + if (upper.contains("SUGAR") || upper.contains("CANE")) { + return Optional.of(SUGAR_CANE); + } + if (upper.contains("MUSHROOM")) { + return Optional.of(MUSHROOM); + } + if (upper.contains("MELON")) { + return Optional.of(MELON); + } + if (upper.contains("PUMPKIN")) { + return Optional.of(PUMPKIN); + } + if (upper.contains("COCOA") || upper.contains("BEAN")) { + return Optional.of(COCOA_BEANS); + } + if (upper.contains("CACTUS")) { + return Optional.of(CACTUS); + } + return Optional.empty(); + } +} diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/jacob/JacobEvidenceIssue.java b/src/main/java/dev/hylfrd/farmhelper/feature/jacob/JacobEvidenceIssue.java new file mode 100644 index 00000000..b47f0254 --- /dev/null +++ b/src/main/java/dev/hylfrd/farmhelper/feature/jacob/JacobEvidenceIssue.java @@ -0,0 +1,14 @@ +package dev.hylfrd.farmhelper.feature.jacob; + +/** Privacy-safe reasons why Jacob evidence cannot produce a trusted threshold decision. */ +public enum JacobEvidenceIssue { + NONE, + SOURCE_UNKNOWN, + INCOMPLETE, + MALFORMED, + UNKNOWN_FORMAT, + OVERFLOW, + INPUT_LIMIT, + CONFLICT, + THRESHOLD_UNAVAILABLE +} diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/jacob/JacobEvidenceParseResult.java b/src/main/java/dev/hylfrd/farmhelper/feature/jacob/JacobEvidenceParseResult.java new file mode 100644 index 00000000..969ab83d --- /dev/null +++ b/src/main/java/dev/hylfrd/farmhelper/feature/jacob/JacobEvidenceParseResult.java @@ -0,0 +1,58 @@ +package dev.hylfrd.farmhelper.feature.jacob; + +import dev.hylfrd.farmhelper.runtime.snapshot.Observation; + +import java.util.Objects; + +/** Immutable tri-state result at the cleaned-text observation boundary. */ +public record JacobEvidenceParseResult( + JacobContestIdentity contest, + long sequence, + Observation evidence, + JacobEvidenceIssue issue +) { + public JacobEvidenceParseResult { + Objects.requireNonNull(contest, "contest"); + Objects.requireNonNull(evidence, "evidence"); + Objects.requireNonNull(issue, "issue"); + if (sequence < 0L) { + throw new IllegalArgumentException("sequence must be non-negative"); + } + if (evidence.isPresent()) { + JacobContestEvidence value = evidence.get(); + if (!value.contest().equals(contest) || value.sequence() != sequence) { + throw new IllegalArgumentException("evidence identity does not match parse context"); + } + if (issue != JacobEvidenceIssue.NONE) { + throw new IllegalArgumentException("present evidence cannot have an issue"); + } + } else if (evidence.isAbsent()) { + if (issue != JacobEvidenceIssue.NONE) { + throw new IllegalArgumentException("absent evidence cannot have an issue"); + } + } else if (issue == JacobEvidenceIssue.NONE) { + throw new IllegalArgumentException("unknown evidence must have an issue"); + } + } + + public static JacobEvidenceParseResult present(JacobContestEvidence evidence) { + Objects.requireNonNull(evidence, "evidence"); + return new JacobEvidenceParseResult( + evidence.contest(), evidence.sequence(), Observation.present(evidence), JacobEvidenceIssue.NONE); + } + + public static JacobEvidenceParseResult absent(JacobContestIdentity contest, long sequence) { + return new JacobEvidenceParseResult(contest, sequence, Observation.absent(), JacobEvidenceIssue.NONE); + } + + public static JacobEvidenceParseResult unknown( + JacobContestIdentity contest, + long sequence, + JacobEvidenceIssue issue + ) { + if (issue == null || issue == JacobEvidenceIssue.NONE) { + throw new IllegalArgumentException("unknown evidence requires a non-NONE issue"); + } + return new JacobEvidenceParseResult(contest, sequence, Observation.unknown(), issue); + } +} diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/jacob/JacobEvidenceParser.java b/src/main/java/dev/hylfrd/farmhelper/feature/jacob/JacobEvidenceParser.java new file mode 100644 index 00000000..6f6c9f4e --- /dev/null +++ b/src/main/java/dev/hylfrd/farmhelper/feature/jacob/JacobEvidenceParser.java @@ -0,0 +1,239 @@ +package dev.hylfrd.farmhelper.feature.jacob; + +import java.math.BigInteger; +import java.util.EnumSet; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Deterministic adapter from cleaned Jacob scoreboard lines to immutable evidence. + * + *

This class models only the server-text observation surface. It does not read a scoreboard or + * a client object; callers provide a stable contest identity and monotonically increasing sequence. + */ +public final class JacobEvidenceParser { + public static final int MAX_LINES = 128; + public static final int MAX_LINE_CHARACTERS = 2_048; + public static final int MAX_NUMERIC_TOKEN_CHARACTERS = 64; + + private static final Pattern REMAINING_TIME = Pattern.compile( + "([0-9]|[12][0-9])m([0-9]|[1-5][0-9])s"); + private static final Pattern TIME_SHAPE = Pattern.compile("[0-9]+m[0-9]+s"); + private static final String COUNT_TOKEN = "[0-9](?:[0-9,. ]*[0-9])?"; + private static final Pattern COLLECTED = Pattern.compile("^Collected (" + COUNT_TOKEN + ")$"); + private static final Pattern MEDAL_THRESHOLD = Pattern.compile( + "^(BRONZE|SILVER|GOLD|PLATINUM|DIAMOND) with (" + COUNT_TOKEN + ")$"); + + /** + * Parses one already-cleaned scoreboard batch. No input line is retained in the result. + */ + public JacobEvidenceParseResult parse( + JacobContestIdentity contest, + long sequence, + List cleanedLines + ) { + Objects.requireNonNull(contest, "contest"); + requireSequence(sequence); + Objects.requireNonNull(cleanedLines, "cleanedLines"); + + List copiedLines; + try { + copiedLines = List.copyOf(cleanedLines); + } catch (NullPointerException exception) { + return JacobEvidenceParseResult.unknown(contest, sequence, JacobEvidenceIssue.INPUT_LIMIT); + } + if (copiedLines.size() > MAX_LINES) { + return JacobEvidenceParseResult.unknown(contest, sequence, JacobEvidenceIssue.INPUT_LIMIT); + } + for (String line : copiedLines) { + if (line.length() > MAX_LINE_CHARACTERS) { + return JacobEvidenceParseResult.unknown(contest, sequence, JacobEvidenceIssue.INPUT_LIMIT); + } + } + + List lines = copiedLines.stream().map(String::trim).toList(); + boolean contestVisible = lines.stream() + .map(line -> line.toUpperCase(Locale.ROOT)) + .anyMatch(line -> line.contains("JACOB'S CONTEST")); + if (!contestVisible) { + return JacobEvidenceParseResult.absent(contest, sequence); + } + + Set crops = EnumSet.noneOf(JacobCrop.class); + for (String line : lines) { + Matcher validTime = REMAINING_TIME.matcher(line); + if (validTime.find()) { + Optional crop = JacobCrop.fromLine(line); + if (crop.isEmpty()) { + return JacobEvidenceParseResult.unknown( + contest, sequence, JacobEvidenceIssue.UNKNOWN_FORMAT); + } + crops.add(crop.get()); + } else if (TIME_SHAPE.matcher(line).find()) { + return JacobEvidenceParseResult.unknown( + contest, sequence, JacobEvidenceIssue.MALFORMED); + } + } + if (crops.isEmpty()) { + return JacobEvidenceParseResult.unknown(contest, sequence, JacobEvidenceIssue.INCOMPLETE); + } + if (crops.size() > 1) { + return JacobEvidenceParseResult.unknown(contest, sequence, JacobEvidenceIssue.CONFLICT); + } + + CountParse count = parseCount(lines); + if (count.issue() != JacobEvidenceIssue.NONE) { + return JacobEvidenceParseResult.unknown(contest, sequence, count.issue()); + } + JacobContestEvidence evidence = new JacobContestEvidence( + contest, crops.iterator().next(), count.value(), sequence); + return JacobEvidenceParseResult.present(evidence); + } + + /** Creates a fail-closed result for a source that could not be captured. */ + public JacobEvidenceParseResult unknown(JacobContestIdentity contest, long sequence) { + return JacobEvidenceParseResult.unknown(contest, sequence, JacobEvidenceIssue.SOURCE_UNKNOWN); + } + + private static CountParse parseCount(List lines) { + List explicit = lines.stream() + .filter(line -> line.toUpperCase(Locale.ROOT).startsWith("COLLECTED")) + .toList(); + if (!explicit.isEmpty()) { + return decodeCountLines(explicit, COLLECTED, 1); + } + + List thresholdLines = lines.stream() + .filter(JacobEvidenceParser::containsWithToken) + .toList(); + if (thresholdLines.isEmpty()) { + return new CountParse(0L, JacobEvidenceIssue.INCOMPLETE); + } + return decodeCountLines(thresholdLines, MEDAL_THRESHOLD, 2); + } + + private static CountParse decodeCountLines( + List lines, + Pattern pattern, + int countGroup + ) { + Long value = null; + for (String line : lines) { + Matcher matcher = pattern.matcher(line); + if (!matcher.matches()) { + return new CountParse(0L, JacobEvidenceIssue.UNKNOWN_FORMAT); + } + long parsed; + try { + parsed = parseInteger(matcher.group(countGroup)); + } catch (CountFailure failure) { + return new CountParse(0L, failure.issue()); + } + if (value != null && value.longValue() != parsed) { + return new CountParse(0L, JacobEvidenceIssue.CONFLICT); + } + value = parsed; + } + return new CountParse(value == null ? 0L : value, JacobEvidenceIssue.NONE); + } + + private static boolean containsWithToken(String line) { + String upper = line.toUpperCase(Locale.ROOT); + return upper.equals("WITH") + || upper.startsWith("WITH ") + || upper.endsWith(" WITH") + || upper.contains(" WITH "); + } + + private static long parseInteger(String token) throws CountFailure { + if (token.length() > MAX_NUMERIC_TOKEN_CHARACTERS) { + throw new CountFailure(JacobEvidenceIssue.INPUT_LIMIT); + } + if (!token.matches(COUNT_TOKEN)) { + throw new CountFailure(JacobEvidenceIssue.MALFORMED); + } + + String canonical; + if (token.indexOf(' ') >= 0) { + if (token.indexOf(',') >= 0 || token.indexOf('.') >= 0) { + throw new CountFailure(JacobEvidenceIssue.MALFORMED); + } + canonical = groupedInteger(token, ' '); + } else { + boolean comma = token.indexOf(',') >= 0; + boolean dot = token.indexOf('.') >= 0; + if (comma && dot) { + throw new CountFailure(JacobEvidenceIssue.MALFORMED); + } + if (comma) { + canonical = groupedInteger(token, ','); + } else if (dot) { + canonical = groupedInteger(token, '.'); + } else { + canonical = token; + } + } + + try { + BigInteger value = new BigInteger(canonical); + if (value.compareTo(BigInteger.valueOf(Long.MAX_VALUE)) > 0) { + throw new CountFailure(JacobEvidenceIssue.OVERFLOW); + } + return value.longValueExact(); + } catch (NumberFormatException | ArithmeticException exception) { + throw new CountFailure(JacobEvidenceIssue.MALFORMED); + } + } + + private static String groupedInteger(String value, char separator) throws CountFailure { + String[] groups = value.split(Pattern.quote(String.valueOf(separator)), -1); + if (groups.length < 2 || groups[0].length() < 1 || groups[0].length() > 3 + || !digits(groups[0])) { + throw new CountFailure(JacobEvidenceIssue.MALFORMED); + } + StringBuilder compact = new StringBuilder(value.length()); + compact.append(groups[0]); + for (int index = 1; index < groups.length; index++) { + if (groups[index].length() != 3 || !digits(groups[index])) { + throw new CountFailure(JacobEvidenceIssue.MALFORMED); + } + compact.append(groups[index]); + } + return compact.toString(); + } + + private static boolean digits(String value) { + for (int index = 0; index < value.length(); index++) { + if (!Character.isDigit(value.charAt(index))) { + return false; + } + } + return true; + } + + private static void requireSequence(long sequence) { + if (sequence < 0L) { + throw new IllegalArgumentException("sequence must be non-negative"); + } + } + + private record CountParse(long value, JacobEvidenceIssue issue) { + } + + private static final class CountFailure extends Exception { + private final JacobEvidenceIssue issue; + + private CountFailure(JacobEvidenceIssue issue) { + this.issue = issue; + } + + private JacobEvidenceIssue issue() { + return issue; + } + } +} diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/jacob/JacobThresholdDetector.java b/src/main/java/dev/hylfrd/farmhelper/feature/jacob/JacobThresholdDetector.java new file mode 100644 index 00000000..6ee7f5a6 --- /dev/null +++ b/src/main/java/dev/hylfrd/farmhelper/feature/jacob/JacobThresholdDetector.java @@ -0,0 +1,126 @@ +package dev.hylfrd.farmhelper.feature.jacob; + +import dev.hylfrd.farmhelper.runtime.snapshot.Observation; + +import java.util.Objects; +import java.util.OptionalLong; + +/** + * Pure stateful threshold policy over immutable parsed evidence. + * + *

Within one contest identity, accepted counts are monotonic. A lower count or older sequence + * is stale and leaves the last trusted decision intact. A different identity starts a fresh + * contest, so a prior contest cannot carry a trigger into the next one. + */ +public final class JacobThresholdDetector { + private final JacobThresholds thresholds; + private JacobThresholdSnapshot snapshot = JacobThresholdSnapshot.empty(); + + public JacobThresholdDetector(JacobThresholds thresholds) { + this.thresholds = Objects.requireNonNull(thresholds, "thresholds"); + } + + public synchronized JacobThresholdSnapshot snapshot() { + return snapshot; + } + + public synchronized JacobThresholdResult observe(JacobEvidenceParseResult parsed) { + Objects.requireNonNull(parsed, "parsed"); + if (parsed.sequence() <= snapshot.sequence()) { + return new JacobThresholdResult(JacobThresholdUpdate.STALE, snapshot); + } + + if (parsed.evidence().isAbsent()) { + snapshot = new JacobThresholdSnapshot( + parsed.sequence(), + Observation.absent(), + Observation.absent(), + Observation.absent(), + JacobThresholdStatus.NO_CONTEST, + JacobEvidenceIssue.NONE); + return accepted(); + } + + if (parsed.evidence().isUnknown()) { + snapshot = unknownSnapshot( + parsed.sequence(), parsed.contest(), parsed.issue()); + return rejected(); + } + + JacobContestEvidence current = parsed.evidence().get(); + JacobContestEvidence previous = retainedEvidence(current.contest()); + if (previous != null) { + if (previous.crop() != current.crop()) { + snapshot = unknownSnapshot( + parsed.sequence(), current.contest(), JacobEvidenceIssue.CONFLICT); + return rejected(); + } + if (current.collectedCount() < previous.collectedCount()) { + return new JacobThresholdResult(JacobThresholdUpdate.STALE, snapshot); + } + } + + OptionalLong threshold = thresholds.thresholdFor(current.crop()); + if (threshold.isEmpty()) { + snapshot = new JacobThresholdSnapshot( + parsed.sequence(), + Observation.present(current.contest()), + Observation.present(current), + Observation.unknown(), + JacobThresholdStatus.UNKNOWN, + JacobEvidenceIssue.THRESHOLD_UNAVAILABLE); + return rejected(); + } + + JacobThresholdStatus status = current.collectedCount() >= threshold.getAsLong() + ? JacobThresholdStatus.TRIGGERED + : JacobThresholdStatus.WAITING; + snapshot = new JacobThresholdSnapshot( + parsed.sequence(), + Observation.present(current.contest()), + Observation.present(current), + Observation.present(threshold.getAsLong()), + status, + JacobEvidenceIssue.NONE); + return accepted(); + } + + public synchronized void reset() { + snapshot = JacobThresholdSnapshot.empty(); + } + + private JacobContestEvidence retainedEvidence(JacobContestIdentity contest) { + if (!snapshot.evidence().isPresent() || !snapshot.evidence().get().contest().equals(contest)) { + return null; + } + return snapshot.evidence().get(); + } + + private JacobThresholdSnapshot unknownSnapshot( + long sequence, + JacobContestIdentity contest, + JacobEvidenceIssue issue + ) { + Observation evidence = snapshot.evidence(); + Observation threshold = snapshot.threshold(); + if (!evidence.isPresent() || !evidence.get().contest().equals(contest)) { + evidence = Observation.absent(); + threshold = Observation.unknown(); + } + return new JacobThresholdSnapshot( + sequence, + Observation.present(contest), + evidence, + threshold, + JacobThresholdStatus.UNKNOWN, + issue); + } + + private JacobThresholdResult accepted() { + return new JacobThresholdResult(JacobThresholdUpdate.ACCEPTED, snapshot); + } + + private JacobThresholdResult rejected() { + return new JacobThresholdResult(JacobThresholdUpdate.REJECTED, snapshot); + } +} diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/jacob/JacobThresholdResult.java b/src/main/java/dev/hylfrd/farmhelper/feature/jacob/JacobThresholdResult.java new file mode 100644 index 00000000..76e27780 --- /dev/null +++ b/src/main/java/dev/hylfrd/farmhelper/feature/jacob/JacobThresholdResult.java @@ -0,0 +1,14 @@ +package dev.hylfrd.farmhelper.feature.jacob; + +import java.util.Objects; + +/** Immutable result pairing one detector update with its stable post-update snapshot. */ +public record JacobThresholdResult( + JacobThresholdUpdate update, + JacobThresholdSnapshot snapshot +) { + public JacobThresholdResult { + Objects.requireNonNull(update, "update"); + Objects.requireNonNull(snapshot, "snapshot"); + } +} diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/jacob/JacobThresholdSnapshot.java b/src/main/java/dev/hylfrd/farmhelper/feature/jacob/JacobThresholdSnapshot.java new file mode 100644 index 00000000..41f3c75d --- /dev/null +++ b/src/main/java/dev/hylfrd/farmhelper/feature/jacob/JacobThresholdSnapshot.java @@ -0,0 +1,81 @@ +package dev.hylfrd.farmhelper.feature.jacob; + +import dev.hylfrd.farmhelper.runtime.snapshot.Observation; + +import java.util.Objects; + +/** Immutable reader view of the last accepted Jacob threshold state. */ +public record JacobThresholdSnapshot( + long sequence, + Observation contest, + Observation evidence, + Observation threshold, + JacobThresholdStatus status, + JacobEvidenceIssue issue +) { + public JacobThresholdSnapshot { + Objects.requireNonNull(contest, "contest"); + Objects.requireNonNull(evidence, "evidence"); + Objects.requireNonNull(threshold, "threshold"); + Objects.requireNonNull(status, "status"); + Objects.requireNonNull(issue, "issue"); + if (sequence < -1L) { + throw new IllegalArgumentException("sequence must be -1 or non-negative"); + } + if (threshold.isPresent() && threshold.get() < 0L) { + throw new IllegalArgumentException("threshold must be non-negative"); + } + if (evidence.isPresent()) { + JacobContestEvidence value = evidence.get(); + if (contest.isPresent() && !contest.get().equals(value.contest())) { + throw new IllegalArgumentException("evidence contest does not match snapshot contest"); + } + } + + switch (status) { + case NO_CONTEST -> { + if (!contest.isAbsent() || !evidence.isAbsent() || !threshold.isAbsent() + || issue != JacobEvidenceIssue.NONE) { + throw new IllegalArgumentException("NO_CONTEST snapshot has unexpected evidence"); + } + } + case WAITING, TRIGGERED -> { + if (!contest.isPresent() || !evidence.isPresent() || !threshold.isPresent() + || issue != JacobEvidenceIssue.NONE) { + throw new IllegalArgumentException("actionable snapshot is incomplete"); + } + JacobContestEvidence value = evidence.get(); + if (value.sequence() != sequence) { + throw new IllegalArgumentException("actionable evidence sequence does not match snapshot"); + } + boolean reached = value.collectedCount() >= threshold.get(); + if ((status == JacobThresholdStatus.TRIGGERED) != reached) { + throw new IllegalArgumentException("snapshot status does not match inclusive threshold rule"); + } + } + case UNKNOWN -> { + if (issue == JacobEvidenceIssue.NONE) { + throw new IllegalArgumentException("UNKNOWN snapshot requires an issue"); + } + } + } + } + + public static JacobThresholdSnapshot empty() { + return new JacobThresholdSnapshot( + -1L, + Observation.absent(), + Observation.absent(), + Observation.absent(), + JacobThresholdStatus.NO_CONTEST, + JacobEvidenceIssue.NONE); + } + + public boolean triggered() { + return status == JacobThresholdStatus.TRIGGERED; + } + + public boolean failClosed() { + return status == JacobThresholdStatus.UNKNOWN; + } +} diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/jacob/JacobThresholdStatus.java b/src/main/java/dev/hylfrd/farmhelper/feature/jacob/JacobThresholdStatus.java new file mode 100644 index 00000000..fee00125 --- /dev/null +++ b/src/main/java/dev/hylfrd/farmhelper/feature/jacob/JacobThresholdStatus.java @@ -0,0 +1,9 @@ +package dev.hylfrd.farmhelper.feature.jacob; + +/** Stable semantic state exposed by the pure threshold detector. */ +public enum JacobThresholdStatus { + NO_CONTEST, + WAITING, + TRIGGERED, + UNKNOWN +} diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/jacob/JacobThresholdUpdate.java b/src/main/java/dev/hylfrd/farmhelper/feature/jacob/JacobThresholdUpdate.java new file mode 100644 index 00000000..18a5997c --- /dev/null +++ b/src/main/java/dev/hylfrd/farmhelper/feature/jacob/JacobThresholdUpdate.java @@ -0,0 +1,8 @@ +package dev.hylfrd.farmhelper.feature.jacob; + +/** Whether one submitted evidence result advanced, was ignored as stale, or failed closed. */ +public enum JacobThresholdUpdate { + ACCEPTED, + STALE, + REJECTED +} diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/jacob/JacobThresholds.java b/src/main/java/dev/hylfrd/farmhelper/feature/jacob/JacobThresholds.java new file mode 100644 index 00000000..dcb11685 --- /dev/null +++ b/src/main/java/dev/hylfrd/farmhelper/feature/jacob/JacobThresholds.java @@ -0,0 +1,43 @@ +package dev.hylfrd.farmhelper.feature.jacob; + +import java.util.Collections; +import java.util.EnumMap; +import java.util.Map; +import java.util.Objects; +import java.util.OptionalLong; + +/** Immutable crop-to-cap values supplied by a composition layer, without shared config coupling. */ +public record JacobThresholds(Map thresholds) { + public JacobThresholds { + Objects.requireNonNull(thresholds, "thresholds"); + EnumMap copied = new EnumMap<>(JacobCrop.class); + for (Map.Entry entry : thresholds.entrySet()) { + JacobCrop crop = Objects.requireNonNull(entry.getKey(), "threshold crop"); + Long threshold = Objects.requireNonNull(entry.getValue(), "threshold value"); + if (threshold < 0L) { + throw new IllegalArgumentException("threshold must be non-negative"); + } + copied.put(crop, threshold); + } + thresholds = Collections.unmodifiableMap(copied); + } + + public static JacobThresholds of(JacobCrop crop, long threshold) { + Objects.requireNonNull(crop, "crop"); + return new JacobThresholds(Map.of(crop, threshold)); + } + + public static JacobThresholds uniform(long threshold) { + EnumMap values = new EnumMap<>(JacobCrop.class); + for (JacobCrop crop : JacobCrop.values()) { + values.put(crop, threshold); + } + return new JacobThresholds(values); + } + + public OptionalLong thresholdFor(JacobCrop crop) { + Objects.requireNonNull(crop, "crop"); + Long threshold = thresholds.get(crop); + return threshold == null ? OptionalLong.empty() : OptionalLong.of(threshold); + } +} diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/jacob/package-info.java b/src/main/java/dev/hylfrd/farmhelper/feature/jacob/package-info.java new file mode 100644 index 00000000..5a3e9e2c --- /dev/null +++ b/src/main/java/dev/hylfrd/farmhelper/feature/jacob/package-info.java @@ -0,0 +1,19 @@ +/** + * Pure Jacob contest threshold evidence and decision domain. + * + *

The observation boundary accepts already-cleaned scoreboard lines and emits immutable, + * privacy-safe evidence. The decision boundary consumes only that evidence and an explicit + * threshold table; it has no Minecraft, client, chat, scoreboard, UI, configuration, or remote + * control dependency. + * + *

Fixed upstream fidelity anchors are from commit + * {@code eacb323fbde3eff94d4f2ee7baacb059d84b8e3a}: + * {@code com.jelly.farmhelperv2.handler.GameStateHandler#checkJacob(String)} uses the text + * anchors {@code jacob's contest}, {@code Collected}, the medal {@code with} lines, and + * {@code jacobsRemainingTimePattern}; {@code + * com.jelly.farmhelperv2.failsafe.impl.JacobFailsafe#onTickDetection(ClientTickEvent)} maps the + * crop caps and triggers when the count is greater than or equal to the selected cap. The first + * class is server-specific observation; the second comparison is the pure decision rule kept + * here. + */ +package dev.hylfrd.farmhelper.feature.jacob; diff --git a/src/test/java/dev/hylfrd/farmhelper/feature/jacob/JacobThresholdDetectorTest.java b/src/test/java/dev/hylfrd/farmhelper/feature/jacob/JacobThresholdDetectorTest.java new file mode 100644 index 00000000..eee332ba --- /dev/null +++ b/src/test/java/dev/hylfrd/farmhelper/feature/jacob/JacobThresholdDetectorTest.java @@ -0,0 +1,224 @@ +package dev.hylfrd.farmhelper.feature.jacob; + +import dev.hylfrd.farmhelper.runtime.snapshot.Observation; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +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 JacobThresholdDetectorTest { + private static final JacobContestIdentity FIRST = new JacobContestIdentity(1L); + private static final JacobContestIdentity SECOND = new JacobContestIdentity(2L); + + private final JacobEvidenceParser parser = new JacobEvidenceParser(); + + @Test + void parsesExplicitCollectedCountAndCopiesTheInputBoundary() { + List lines = new ArrayList<>(List.of( + "Jacob's Contest", "Wheat 2m30s", "Collected 1,234")); + + JacobEvidenceParseResult result = parser.parse(FIRST, 1L, lines); + lines.set(2, "Collected 9,999"); + + assertTrue(result.evidence().isPresent()); + assertEquals(FIRST, result.evidence().get().contest()); + assertEquals(JacobCrop.WHEAT, result.evidence().get().crop()); + assertEquals(1_234L, result.evidence().get().collectedCount()); + assertEquals(JacobEvidenceIssue.NONE, result.issue()); + } + + @Test + void preservesRecognizedMedalFallbackAndRejectsMalformedCountForms() { + for (String medal : List.of("BRONZE", "SILVER", "GOLD", "PLATINUM", "DIAMOND")) { + JacobEvidenceParseResult result = parser.parse(FIRST, 1L, + List.of("Jacob's Contest", "Wheat 2m30s", medal + " with 2,345")); + assertEquals(2_345L, result.evidence().get().collectedCount(), medal); + } + + JacobEvidenceParseResult malformedNumber = parser.parse(FIRST, 2L, + List.of("Jacob's Contest", "Wheat 2m30s", "GOLD with 12,34")); + assertTrue(malformedNumber.evidence().isUnknown()); + assertEquals(JacobEvidenceIssue.MALFORMED, malformedNumber.issue()); + + JacobEvidenceParseResult unknownLabel = parser.parse(FIRST, 3L, + List.of("Jacob's Contest", "Wheat 2m30s", "GOLDEN with 1,234")); + assertTrue(unknownLabel.evidence().isUnknown()); + assertEquals(JacobEvidenceIssue.UNKNOWN_FORMAT, unknownLabel.issue()); + } + + @Test + void rejectsMalformedTimeUnknownCropAndConflictingContext() { + JacobEvidenceParseResult badTime = parser.parse(FIRST, 1L, + List.of("Jacob's Contest", "Wheat 2m99s", "Collected 10")); + assertEquals(JacobEvidenceIssue.MALFORMED, badTime.issue()); + + JacobEvidenceParseResult badCrop = parser.parse(FIRST, 2L, + List.of("Jacob's Contest", "Mysterious Crop 2m30s", "Collected 10")); + assertEquals(JacobEvidenceIssue.UNKNOWN_FORMAT, badCrop.issue()); + + JacobEvidenceParseResult conflictingCrop = parser.parse(FIRST, 3L, + List.of("Jacob's Contest", "Wheat 2m30s", "Carrot 2m30s", "Collected 10")); + assertEquals(JacobEvidenceIssue.CONFLICT, conflictingCrop.issue()); + + JacobEvidenceParseResult incomplete = parser.parse(FIRST, 4L, + List.of("Jacob's Contest", "Wheat 2m30s")); + assertEquals(JacobEvidenceIssue.INCOMPLETE, incomplete.issue()); + } + + @Test + void distinguishesNoContestFromUnknownAndBoundsNumericInput() { + JacobEvidenceParseResult absent = parser.parse(FIRST, 1L, List.of("Area: Garden")); + assertTrue(absent.evidence().isAbsent()); + + JacobEvidenceParseResult sourceUnknown = parser.unknown(FIRST, 2L); + assertTrue(sourceUnknown.evidence().isUnknown()); + assertEquals(JacobEvidenceIssue.SOURCE_UNKNOWN, sourceUnknown.issue()); + + JacobEvidenceParseResult overflow = parser.parse(FIRST, 3L, + List.of("Jacob's Contest", "Wheat 2m30s", "Collected 9,223,372,036,854,775,808")); + assertEquals(JacobEvidenceIssue.OVERFLOW, overflow.issue()); + + JacobEvidenceParseResult inputLimit = parser.parse(FIRST, 4L, + List.of("Jacob's Contest", "Wheat 2m30s", "Collected " + "9".repeat(65))); + assertEquals(JacobEvidenceIssue.INPUT_LIMIT, inputLimit.issue()); + } + + @Test + void usesInclusiveBoundaryAndStrictlyBelowWaits() { + JacobThresholdDetector detector = new JacobThresholdDetector( + JacobThresholds.of(JacobCrop.WHEAT, 100L)); + + JacobThresholdResult below = detector.observe(evidence(FIRST, JacobCrop.WHEAT, 99L, 1L)); + assertEquals(JacobThresholdUpdate.ACCEPTED, below.update()); + assertEquals(JacobThresholdStatus.WAITING, below.snapshot().status()); + assertFalse(below.snapshot().triggered()); + + JacobThresholdResult exact = detector.observe(evidence(FIRST, JacobCrop.WHEAT, 100L, 2L)); + assertEquals(JacobThresholdUpdate.ACCEPTED, exact.update()); + assertEquals(JacobThresholdStatus.TRIGGERED, exact.snapshot().status()); + assertTrue(exact.snapshot().triggered()); + } + + @Test + void lowerCountsAndOlderSequencesCannotRegressTrustedState() { + JacobThresholdDetector detector = new JacobThresholdDetector( + JacobThresholds.of(JacobCrop.WHEAT, 100L)); + + JacobThresholdSnapshot waiting = detector.observe( + evidence(FIRST, JacobCrop.WHEAT, 90L, 10L)).snapshot(); + JacobThresholdResult lower = detector.observe( + evidence(FIRST, JacobCrop.WHEAT, 80L, 11L)); + assertEquals(JacobThresholdUpdate.STALE, lower.update()); + assertEquals(waiting, lower.snapshot()); + + JacobThresholdSnapshot triggered = detector.observe( + evidence(FIRST, JacobCrop.WHEAT, 100L, 12L)).snapshot(); + JacobThresholdResult old = detector.observe( + evidence(FIRST, JacobCrop.WHEAT, 99L, 11L)); + assertEquals(JacobThresholdUpdate.STALE, old.update()); + assertEquals(triggered, old.snapshot()); + + JacobThresholdResult lowerAfterTrigger = detector.observe( + evidence(FIRST, JacobCrop.WHEAT, 99L, 13L)); + assertEquals(JacobThresholdUpdate.STALE, lowerAfterTrigger.update()); + assertTrue(lowerAfterTrigger.snapshot().triggered()); + assertEquals(100L, lowerAfterTrigger.snapshot().evidence().get().collectedCount()); + } + + @Test + void explicitContestIdentityResetsHighWaterMark() { + JacobThresholdDetector detector = new JacobThresholdDetector( + JacobThresholds.of(JacobCrop.WHEAT, 100L)); + + detector.observe(evidence(FIRST, JacobCrop.WHEAT, 100L, 1L)); + JacobThresholdResult secondContest = detector.observe( + evidence(SECOND, JacobCrop.WHEAT, 1L, 2L)); + + assertEquals(JacobThresholdUpdate.ACCEPTED, secondContest.update()); + assertEquals(JacobThresholdStatus.WAITING, secondContest.snapshot().status()); + assertEquals(SECOND, secondContest.snapshot().contest().get()); + assertEquals(1L, secondContest.snapshot().evidence().get().collectedCount()); + } + + @Test + void cropChangeWithinOneContestFailsClosedUntilIdentityChanges() { + JacobThresholdDetector detector = new JacobThresholdDetector( + new JacobThresholds(Map.of(JacobCrop.WHEAT, 100L, JacobCrop.CARROT, 100L))); + + detector.observe(evidence(FIRST, JacobCrop.WHEAT, 50L, 1L)); + JacobThresholdResult conflict = detector.observe( + evidence(FIRST, JacobCrop.CARROT, 200L, 2L)); + assertEquals(JacobThresholdUpdate.REJECTED, conflict.update()); + assertEquals(JacobThresholdStatus.UNKNOWN, conflict.snapshot().status()); + assertEquals(JacobEvidenceIssue.CONFLICT, conflict.snapshot().issue()); + assertFalse(conflict.snapshot().triggered()); + } + + @Test + void malformedAndUnknownEvidenceNeverTriggersAndCanRecoverWithFreshEvidence() { + JacobThresholdDetector detector = new JacobThresholdDetector( + JacobThresholds.of(JacobCrop.WHEAT, 100L)); + + JacobEvidenceParseResult malformed = parser.parse(FIRST, 1L, + List.of("Jacob's Contest", "Wheat 2m30s", "GOLD with 12,34")); + JacobThresholdResult rejected = detector.observe(malformed); + assertEquals(JacobThresholdUpdate.REJECTED, rejected.update()); + assertEquals(JacobThresholdStatus.UNKNOWN, rejected.snapshot().status()); + assertTrue(rejected.snapshot().failClosed()); + assertFalse(rejected.snapshot().triggered()); + + JacobThresholdResult recovered = detector.observe( + evidence(FIRST, JacobCrop.WHEAT, 100L, 2L)); + assertEquals(JacobThresholdStatus.TRIGGERED, recovered.snapshot().status()); + + JacobThresholdResult unknown = detector.observe(parser.unknown(FIRST, 3L)); + assertEquals(JacobThresholdStatus.UNKNOWN, unknown.snapshot().status()); + assertFalse(unknown.snapshot().triggered()); + } + + @Test + void missingThresholdFailsClosedAndThresholdMapsAreStable() { + JacobThresholds thresholds = new JacobThresholds(Map.of(JacobCrop.WHEAT, 100L)); + assertThrows(UnsupportedOperationException.class, + () -> thresholds.thresholds().put(JacobCrop.CARROT, 10L)); + + JacobThresholdDetector detector = new JacobThresholdDetector(thresholds); + JacobThresholdResult missing = detector.observe( + evidence(FIRST, JacobCrop.CARROT, 10L, 1L)); + assertEquals(JacobThresholdUpdate.REJECTED, missing.update()); + assertEquals(JacobThresholdStatus.UNKNOWN, missing.snapshot().status()); + assertEquals(JacobEvidenceIssue.THRESHOLD_UNAVAILABLE, missing.snapshot().issue()); + assertFalse(missing.snapshot().triggered()); + } + + @Test + void snapshotsAreStableValueObjects() { + JacobContestEvidence evidence = new JacobContestEvidence(FIRST, JacobCrop.WHEAT, 100L, 7L); + JacobThresholdDetector detector = new JacobThresholdDetector( + JacobThresholds.of(JacobCrop.WHEAT, 100L)); + JacobThresholdSnapshot first = detector.observe( + JacobEvidenceParseResult.present(evidence)).snapshot(); + JacobThresholdSnapshot second = detector.snapshot(); + + assertEquals(first, second); + assertEquals(Observation.present(evidence), first.evidence()); + assertEquals(100L, first.threshold().get()); + assertEquals(JacobThresholdStatus.TRIGGERED, first.status()); + } + + private static JacobEvidenceParseResult evidence( + JacobContestIdentity contest, + JacobCrop crop, + long count, + long sequence + ) { + return JacobEvidenceParseResult.present( + new JacobContestEvidence(contest, crop, count, sequence)); + } +}