publish eve_rock_watcher.py

This commit is contained in:
brockdarnold 2026-06-15 04:51:28 +00:00
parent 19880dbf36
commit c356fc4fd3

View file

@ -26,6 +26,7 @@ import re
import socket import socket
import sys import sys
import time import time
from collections import deque
HERE = os.path.dirname(os.path.abspath(__file__)) HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE) sys.path.insert(0, HERE)
@ -80,6 +81,35 @@ def parse_selected(text):
return int(digits) if digits and len(digits) >= 2 else None return int(digits) if digits and len(digits) >= 2 else None
# The selected rock's info popup shows "Quantity 47,765 Units". That popup FLOATS (it
# follows the rock on screen), so a fixed region can't track it. Instead OCR the whole
# game window and find that text wherever it is — anchored on "Quantity ... Units" so it
# won't false-match the overview (which lists distance/size, never a unit quantity).
QUANTITY_RE = re.compile(r"quantit[yvſ]\s*[:.]?\s*([\d.,]{2,})\s*units?", re.I)
def read_selected_quantity(cp):
"""Return (units, ore) for the currently-selected rock from its floating info popup,
found anywhere in the EVE window. (None, None) if no rock is selected/visible."""
import pytesseract
tcmd = cp.get("ocr", "tesseract_cmd", fallback="").strip()
if tcmd:
pytesseract.pytesseract.tesseract_cmd = tcmd
img = w.capture_window()
if img is None:
return None, None
text = pytesseract.image_to_string(img, config="--psm 6")
flat = text.replace("\n", " ")
m = QUANTITY_RE.search(flat)
if not m:
return None, None
digits = re.sub(r"\D", "", m.group(1))
if len(digits) < 2:
return None, None
ore = match_ore(flat.lower()) or "rock"
return int(digits), ore
def save_rock_region(cp, l, t, wd, ht, mode="survey"): def save_rock_region(cp, l, t, wd, ht, mode="survey"):
if not cp.has_section("rock"): if not cp.has_section("rock"):
cp.add_section("rock") cp.add_section("rock")
@ -273,19 +303,14 @@ def main():
# wrong "~1s, switch now" ETAs. Until OCR is trustworthy, DON'T post from here; the # wrong "~1s, switch now" ETAs. Until OCR is trustworthy, DON'T post from here; the
# reliable switch-rocks signal is the gamelog mining-stall alert in the combat watcher. # reliable switch-rocks signal is the gamelog mining-stall alert in the combat watcher.
# Flip [rock] post_rock=true to re-enable once OCR reads are validated. # Flip [rock] post_rock=true to re-enable once OCR reads are validated.
post_rock = cp.getboolean("rock", "post_rock", fallback=False) if sec else False post_rock = cp.getboolean("rock", "post_rock", fallback=True) if sec else True
if "--test" in sys.argv: if "--test" in sys.argv:
import pytesseract units, ore = read_selected_quantity(cp)
tcmd = cp.get("ocr", "tesseract_cmd", fallback="").strip() if units is None:
if tcmd: print("READ none — no 'Quantity N Units' popup visible (select your rock).")
pytesseract.pytesseract.tesseract_cmd = tcmd else:
region, mode = get_region(cp) print(f"READ ore={ore} units={units:,}")
if not region:
print("Couldn't find a Survey Scanner window or a Selected-Item 'Quantity N "
"Units'. Select a rock (or open the survey results) and retry.")
return
print(f"mode={mode} rows:", read_rows(cp, region, mode))
return return
import pytesseract import pytesseract
@ -293,81 +318,53 @@ def main():
if tcmd: if tcmd:
pytesseract.pytesseract.tesseract_cmd = tcmd pytesseract.pytesseract.tesseract_cmd = tcmd
poll = max(poll, 8) # full-window OCR is heavy; don't spin
print(f"[rock] started; switch<{switch_secs}s, poll {poll}s (waits for !mining on)") print(f"[rock] started; switch<{switch_secs}s, poll {poll}s (waits for !mining on)")
tracker = Tracker() hist = deque() # (t, units) for the currently-selected rock
last_alert = {}
last_status = 0.0 last_status = 0.0
region = mode = None last_alert = 0.0
empty_streak = 0
while True: while True:
if not w.bot_mining(cp): # only during a mining session if not w.bot_mining(cp): # only during a mining session
tracker.h.clear() hist.clear()
time.sleep(15) time.sleep(15)
continue continue
if region is None:
region, mode = get_region(cp)
if region is None:
print("[rock] no survey window / selected rock visible — select a rock. "
"Retrying...")
time.sleep(max(poll, 15))
continue
w.heartbeat(cp, "rock")
try: try:
rows = read_rows(cp, region, mode) units, ore = read_selected_quantity(cp)
if not rows: now = time.time()
# The saved region may be stale (you compressed, reselected, or the window if units is None: # no rock selected / popup not shown
# moved), so a fixed box reads empty forever. After a short empty streak,
# re-acquire fresh: prefer the Survey Scanner window (selection-independent —
# survives compression), then fall back to the Selected-Item panel.
empty_streak += 1
if empty_streak % 6 == 0:
nm = None
r = detect_survey_region(cp)
if r:
region, mode, nm = r, "survey", "survey"
else:
r = detect_selected_region(cp)
if r:
region, mode, nm = r, "selected", "selected"
print(f"[rock] empty {empty_streak}x — re-detect: {nm or 'still nothing visible'}")
time.sleep(poll) time.sleep(poll)
continue continue
empty_streak = 0 # quantity jumped UP -> you locked a fresh rock; restart the measurement
now = time.time() if hist and units > hist[-1][1] + 50:
keyed = tracker.update(rows, now) hist.clear()
hr = hold_rate() hist.append((now, units))
actives = [] # (key, ore, units, tleft) while hist and now - hist[0][0] > 180: # keep a ~3 min window
for key, ore, units in keyed: hist.popleft()
r = tracker.rate(key) # measured units/sec # true depletion rate from the number falling — captures ALL miners on the rock
if r <= 0 and len(keyed) == 1 and hr > 0 and ORE_VOL.get(ore): rate = 0.0 # units/sec
r = hr / 60.0 / ORE_VOL[ore] # single-rock hold-fill fallback if len(hist) >= 2 and (hist[-1][0] - hist[0][0]) >= 20:
if r <= 0: du, dt = hist[0][1] - hist[-1][1], hist[-1][0] - hist[0][0]
continue rate = du / dt if du > 0 else 0.0
actives.append((key, ore, units, units / r)) tleft = units / rate if rate > 0 else None
# the rock you're emptying = the one with the least time left # live readout (edit-in-place, no spam)
actives.sort(key=lambda x: x[3]) if post_rock and not w.bot_muted(cp) and now - last_status >= status_secs:
for key, ore, units, tleft in actives: if tleft:
if post_rock and tleft <= switch_secs and now - last_alert.get(key, 0) > cooldown:
last_alert[key] = now
others = [(o, u) for (k, o, u) in keyed if k != key]
nxt = max(others, key=lambda x: x[1]) if others else None
empty_at = int(now + tleft) empty_at = int(now + tleft)
msg = f"{ore.title()} rock empties in ~{_fmt_dur(tleft)} (<t:{empty_at}:R>) — {units:,} u left." line = (f"🪨 {ore.title()} {units:,} u · ~{_fmt_dur(tleft)} left "
if nxt: f"(empties <t:{empty_at}:R>) · {rate*60:,.0f} u/min")
msg += f" Switch to {nxt[0].title()} ({nxt[1]:,} u)." else:
w.notify(cp, "Switch rocks", msg, priority="high", tags="pick,gem") line = f"🪨 {ore.title()} {units:,} u · measuring rate…"
print(f"[rock] ALERT {msg}")
# periodic live readout of the rock you're on (so you see the countdown)
if post_rock and actives and not w.bot_muted(cp) and now - last_status >= status_secs:
key, ore, units, tleft = actives[0]
empty_at = int(now + tleft)
others = [(o, u) for (k, o, u) in keyed if k != key]
nxt = max(others, key=lambda x: x[1]) if others else None
line = f"🪨 {ore.title()} {units:,} u · ~{_fmt_dur(tleft)} left (empties <t:{empty_at}:R>)"
if nxt:
line += f" · next: {nxt[0].title()} ({nxt[1]:,} u)"
w._discord_live(cp, "rock", f"⛏️ {socket.gethostname()} current rock", line) w._discord_live(cp, "rock", f"⛏️ {socket.gethostname()} current rock", line)
last_status = now last_status = now
# switch-rocks alert when it's genuinely about to empty
if post_rock and tleft and tleft <= switch_secs and now - last_alert > cooldown:
last_alert = now
empty_at = int(now + tleft)
w.notify(cp, "Switch rocks",
f"{ore.title()} rock empties in ~{_fmt_dur(tleft)} "
f"(<t:{empty_at}:R>) — {units:,} u left. Lock a new rock.",
priority="high", tags="pick,gem")
print(f"[rock] ALERT {ore} {units} u, ~{_fmt_dur(tleft)} left")
except Exception as e: except Exception as e:
print(f"[rock] error: {e}") print(f"[rock] error: {e}")
time.sleep(poll) time.sleep(poll)