eve-watcher/eve_rock_watcher.py

471 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Rock watcher — 'when do I switch asteroids?' alerts from your Survey Scanner.
EVE has no API for asteroid contents, so this reads the **Survey Scanner Results**
window off the screen (the only place that shows units remaining per rock). It tracks
each rock's amount over time and tells you, in Discord, when the rock you're mining is
about to run dry — and which rock to jump to next.
Two ways it knows your depletion rate (best available wins):
1. Direct — if the survey scanner is left running, each rock's number drops every
cycle; it measures units/sec straight from that (most accurate, multi-rock).
2. Hold-fill fallback — if there's a single rock and the scanner is static, it uses
the live ore-hold fill rate (written by eve_orehold_watcher to .mining_rate)
divided by the ore's m³/unit.
Only active during a mining session: start one with `!mining on` in Discord
(`!mining off` to stop). Runs headless, launched by start-all.ps1.
python eve_rock_watcher.py # normal (waits for !mining on)
python eve_rock_watcher.py --snip # manually box the survey window (if auto fails)
python eve_rock_watcher.py --test # parse one screen grab and print rows
"""
import json
import os
import re
import socket
import sys
import time
from collections import deque
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import eve_orehold_watcher as w # load_config, notify, grab_region, heartbeat, bot_mining
# ore unit volumes (m³/unit) for the hold-fill fallback; base names, longest first
ORE_VOL = {
"dark ochre": 8.0, "veldspar": 0.1, "scordite": 0.15, "pyroxeres": 0.3,
"plagioclase": 0.35, "omber": 0.6, "kernite": 1.2, "jaspet": 2.0,
"hemorphite": 3.0, "hedbergite": 3.0, "gneiss": 5.0, "crokite": 16.0,
"spodumain": 16.0, "bistot": 16.0, "arkonor": 16.0, "mercoxit": 40.0,
}
ORE_NAMES = sorted(ORE_VOL, key=len, reverse=True) # match "dark ochre" before "ochre"
def match_ore(line_lower):
for name in ORE_NAMES:
if name in line_lower:
return name
return None
def parse_survey(text):
"""OCR text of the Survey Scanner Results -> [(ore_base_name, units), ...]."""
rows = []
for raw in text.splitlines():
line = raw.strip()
if not line:
continue
ore = match_ore(line.lower())
if not ore:
continue
ints = [int(n.replace(",", "")) for n in re.findall(r"\d[\d,]*", line)]
ints = [n for n in ints if n >= 10] # drop tiny (distance fragments)
if not ints:
continue
rows.append((ore, max(ints))) # units is the big number on the row
return rows
# Selected-Item panel (no survey scanner needed): "...Quantity 40,370 Units".
# Match the NUMBER before "Units" — don't require the word "Quantity" (OCR drops it).
QTY_RE = re.compile(r"([\d.,]{2,})\s*units?\b", re.I)
def parse_selected(text):
"""The selected asteroid's remaining units from its info panel, or None."""
m = QTY_RE.search(text.replace("\n", " "))
if not m:
return None
digits = re.sub(r"\D", "", m.group(1)) # tolerate OCR , vs . in the number
return int(digits) if digits and len(digits) >= 2 else None
# The selected rock's info popup shows "Quantity 47,765 Units" (with "Total Value ... ISK"
# and "Distance ..." above it). That popup FLOATS, so a fixed region can't track it — OCR
# the whole window and find the text wherever it is. Primary anchor: "Quantity N Units".
# Fallback: any "N Units" (3+ digits) in a plausible asteroid range — the overview lists
# distance/size, never "Units", so this won't false-match.
QUANTITY_RE = re.compile(r"quantit[yvſ]\s*[:.]?\s*([\d.,]{2,})\s*units?", re.I)
UNITS_RE = re.compile(r"([\d.,]{3,})\s*units?\b", re.I)
def read_selected_quantity(cp):
"""Return (units, ore) for the currently-selected rock from its floating info popup.
Two-pass: (1) locate the word 'Units' anywhere in the window via sparse OCR, then
(2) crop just that line, zoom + grayscale it, and OCR the digits cleanly — small text
over the nebula garbles in a full-window pass ('5b Units'), but reads fine zoomed in."""
import pytesseract
from PIL import Image # noqa: F401 (capture_window already returns a PIL image)
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
try:
d = pytesseract.image_to_data(img, config="--psm 11",
output_type=pytesseract.Output.DICT)
except Exception:
return None, None
n = len(d["text"])
cands = [i for i in range(n) if d["text"][i].strip().lower().startswith("unit")]
dbg = []
for i in cands:
uy, uh, ux = d["top"][i], d["height"][i], d["left"][i]
# crop the line just LEFT of "Units" — that's "Quantity <number>"
box = img.crop((max(0, ux - 320), max(0, uy - 8), ux + 6, uy + uh + 8))
z = box.convert("L").resize((box.width * 4, box.height * 4))
line = pytesseract.image_to_string(
z, config="--psm 7 -c tessedit_char_whitelist=0123456789,QuantiyUns ")
dbg.append(repr(line.strip()))
mm = re.search(r"([\d,]{3,})", line)
if not mm:
continue
v = int(re.sub(r"\D", "", mm.group(1)) or 0)
if not (100 <= v <= 5_000_000): # plausible asteroid remaining
continue
# ore name = a token a little above the 'Units' line, same popup block
ore = "rock"
for j in range(n):
if d["text"][j].strip() and 0 < (uy - d["top"][j]) < 120 \
and abs(d["left"][j] - (ux - 160)) < 260:
o = match_ore(d["text"][j].strip().lower())
if o:
ore = o
break
return v, ore
try:
with open(os.path.join(w.HERE, "_rockocr.txt"), "w", encoding="utf-8") as fh:
fh.write(f"units-candidates={len(cands)} crops={dbg}\n")
except Exception:
pass
return None, None
def save_rock_region(cp, l, t, wd, ht, mode="survey"):
if not cp.has_section("rock"):
cp.add_section("rock")
cp["rock"]["region"] = f"{l},{t},{wd},{ht}"
cp["rock"]["mode"] = mode # 'survey' rows or 'selected' item
with open(w.CONFIG_PATH, "w") as f:
cp.write(f)
def detect_selected_region(cp):
"""Find the Selected Item 'Quantity N Units' line WITHIN the EVE window capture
(clean, occlusion-proof). Returns a WINDOW-RELATIVE tight [l,t,w,h] or None."""
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
S = 2
d = pytesseract.image_to_data(img.resize((img.width*S, img.height*S)),
output_type=pytesseract.Output.DICT)
n = len(d["text"])
for i in range(n):
if "unit" not in d["text"][i].strip().lower():
continue
uy, uh, ux, ur = d["top"][i], d["height"][i], d["left"][i], d["left"][i] + d["width"][i]
# the number is the word(s) just LEFT of "Units" on the same line
nums = [j for j in range(n) if d["text"][j].strip() and abs(d["top"][j] - uy) < uh
and 0 < (ux - d["left"][j]) < 340 * S and re.search(r"\d", d["text"][j])]
if not nums:
continue
l = min(d["left"][j] for j in nums) // S
reg = [max(0, l - 20), max(0, uy // S - 6), (ur // S - l) + 45, uh // S + 12]
save_rock_region(cp, *reg, mode="selected")
print(f"[rock] Selected-Item quantity region (window-relative) -> {reg}")
return reg
return None
def detect_survey_region(cp):
"""Find the Survey Scanner Results window by locating >=2 'ore name + number' rows."""
import mss
import pytesseract
from PIL import Image
tcmd = cp.get("ocr", "tesseract_cmd", fallback="").strip()
if tcmd:
pytesseract.pytesseract.tesseract_cmd = tcmd
try:
with mss.mss() as sct:
mon = sct.monitors[0]
raw = sct.grab(mon)
img = Image.frombytes("RGB", raw.size, raw.bgra, "raw", "BGRX")
data = pytesseract.image_to_data(img, output_type=pytesseract.Output.DICT)
except Exception as e:
print(f"[rock] detect error: {e}")
return None
lines = {}
for i in range(len(data["text"])):
if not data["text"][i].strip():
continue
k = (data["block_num"][i], data["par_num"][i], data["line_num"][i])
lines.setdefault(k, []).append(i)
boxes = []
for idxs in lines.values():
joined = " ".join(data["text"][i] for i in idxs).lower()
if match_ore(joined) and re.search(r"\d", joined):
xs = [data["left"][i] for i in idxs]
ys = [data["top"][i] for i in idxs]
rs = [data["left"][i] + data["width"][i] for i in idxs]
bs = [data["top"][i] + data["height"][i] for i in idxs]
boxes.append((min(xs), min(ys), max(rs), max(bs)))
if len(boxes) < 2:
return None
pad = 12
l = mon["left"] + min(b[0] for b in boxes) - pad
t = mon["top"] + min(b[1] for b in boxes) - pad
wd = max(b[2] for b in boxes) - min(b[0] for b in boxes) + 2 * pad
ht = max(b[3] for b in boxes) - min(b[1] for b in boxes) + 2 * pad
save_rock_region(cp, l, t, wd, ht)
print(f"[rock] auto-detected survey window -> {l},{t},{wd},{ht} ({len(boxes)} rows)")
return [l, t, wd, ht]
def hold_rate():
"""Live ore-hold fill rate (m³/min) written by eve_orehold_watcher, if fresh."""
try:
d = json.load(open(w.RATE_PATH))
if time.time() - d.get("ts", 0) <= 120:
return float(d.get("rate_m3min", 0) or 0)
except Exception:
pass
return 0.0
class Tracker:
"""Per-rock unit history -> measured depletion rate (units/sec)."""
def __init__(self):
self.h = {} # key -> [(t, units)]
def update(self, rows, now):
counts, seen, keyed = {}, set(), []
for ore, units in rows:
ore = ore.lower()
counts[ore] = counts.get(ore, 0) + 1
key = f"{ore}#{counts[ore]}"
keyed.append((key, ore, units))
seen.add(key)
hist = self.h.setdefault(key, [])
if hist and units > hist[-1][1] + 1: # went up = new rock / rescan -> reset
hist.clear()
hist.append((now, units))
self.h[key] = [(t, u) for (t, u) in hist if now - t <= 120]
for key in list(self.h): # forget rocks no longer listed
if key not in seen:
del self.h[key]
return keyed
def rate(self, key):
hist = self.h.get(key, [])
if len(hist) < 2 or hist[-1][0] - hist[0][0] < 15:
return 0.0
du = hist[0][1] - hist[-1][1]
dt = hist[-1][0] - hist[0][0]
return du / dt if du > 0 else 0.0
def get_region(cp):
"""Return (region, mode). mode = 'survey' (scanner rows) or 'selected' (Selected Item)."""
if cp.has_section("rock"):
rs = cp.get("rock", "region", fallback="").strip()
if rs:
return [int(x) for x in rs.split(",")], cp.get("rock", "mode", fallback="survey")
print("[rock] no saved region — locating the Selected-Item Quantity line...")
r = detect_selected_region(cp) # primary: the rock you have selected
if r:
return r, "selected"
r = detect_survey_region(cp) # fallback: a Survey Scanner window
if r:
return r, "survey"
return None, None
def read_rows(cp, region, mode):
import pytesseract
tcmd = cp.get("ocr", "tesseract_cmd", fallback="").strip()
if tcmd: # Tesseract isn't always on PATH; set it explicitly
pytesseract.pytesseract.tesseract_cmd = tcmd
img = w.grab_region(region)
try: # upscale: small panel text reads better
img = img.resize((img.width * 2, img.height * 2))
except Exception:
pass
text = pytesseract.image_to_string(img, config="--psm 6")
if mode == "selected":
q = parse_selected(text)
return [("rock", q)] if q else []
return parse_survey(text)
def _fmt_dur(secs):
"""Human time-left, e.g. '1h 04m', '7m 20s', '45s'."""
secs = int(max(0, secs))
h, rem = divmod(secs, 3600)
m, s = divmod(rem, 60)
if h:
return f"{h}h {m:02d}m"
if m:
return f"{m}m {s:02d}s"
return f"{s}s"
def main():
cp = w.load_config()
if "--snip" in sys.argv:
import importlib
# reuse the ore-hold snipper UI but save to [rock]
l = w.run_snip.__doc__ # noqa: F841 (keep import side effect minimal)
print("Drag a box around the whole Survey Scanner Results list.")
_snip_to_rock(cp)
return
sec = cp.has_section("rock")
poll = cp.getint("rock", "poll_secs", fallback=5) if sec else 5
switch_secs = cp.getint("rock", "switch_secs", fallback=45) if sec else 45
cooldown = cp.getint("rock", "cooldown_secs", fallback=60) if sec else 60
status_secs = cp.getint("rock", "status_secs", fallback=45) if sec else 45
# OCR rock reads are noisy: the Selected-Item quantity jumps as you click around, and
# survey-region detection false-matches the overview's ore names -> invented rocks +
# 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.
# Flip [rock] post_rock=true to re-enable once OCR reads are validated.
post_rock = cp.getboolean("rock", "post_rock", fallback=True) if sec else True
if "--test" in sys.argv:
units, ore = read_selected_quantity(cp)
if units is None:
print("READ none — no 'Quantity N Units' popup visible (select your rock).")
else:
print(f"READ ore={ore} units={units:,}")
return
import pytesseract
tcmd = cp.get("ocr", "tesseract_cmd", fallback="").strip()
if tcmd:
pytesseract.pytesseract.tesseract_cmd = tcmd
poll = max(poll, 8) # full-window OCR is heavy; don't spin
MAX_DROP_PS = 4000 / 60.0 # units/sec ceiling — a faster "drop" than this is a misread
print(f"[rock] started; switch<{switch_secs}s, poll {poll}s (waits for !mining on)")
idle_secs = cp.getint("rock", "idle_secs", fallback=150) if sec else 150
hist = deque() # accepted (t, units), cleaned of OCR outliers
pending = None # a jumped value awaiting a confirming second read
last_status = 0.0
last_alert = 0.0
last_ore = "rock"
last_decrease = 0.0 # last time the rock actually got smaller (= active mining)
while True:
if not w.bot_mining(cp): # only during a mining session
hist.clear(); pending = None
time.sleep(15)
continue
try:
units, ore = read_selected_quantity(cp)
now = time.time()
if units is None: # no rock selected / popup not shown
time.sleep(poll)
continue
if ore and ore != "rock":
last_ore = ore
# ---- reject OCR misreads (a stray digit makes 34,569 read as ~234,000) ----
if not hist:
hist.append((now, units)); pending = None
else:
lt, lu = hist[-1]
dt = max(1.0, now - lt)
if (lu - units) <= MAX_DROP_PS * dt + 400 and units <= lu + 40:
if units < lu - 5:
last_decrease = now # rock is actually shrinking
hist.append((now, units)); pending = None # normal depletion / flat
elif pending is not None and abs(units - pending) <= max(400, units * 0.03):
hist.clear(); hist.append((now, units)); pending = None # new rock, confirmed
else:
pending = units # outlier or jump — wait for confirmation
time.sleep(poll); continue
while hist and now - hist[0][0] > 180: # keep a ~3 min window
hist.popleft()
# ---- depletion rate from the cleaned series (captures ALL miners on the rock) ----
rate = 0.0 # units/sec
if len(hist) >= 3 and (hist[-1][0] - hist[0][0]) >= 30:
du, dt = hist[0][1] - hist[-1][1], hist[-1][0] - hist[0][0]
r = du / dt if du > 0 else 0.0
if 0 < r <= MAX_DROP_PS: # ignore impossible rates
rate = r
tleft = units / rate if rate > 0 else None
# if the rock hasn't shrunk in a while you've stopped mining it (done, or warped
# off) — go quiet instead of editing a stale readout / firing switch alerts.
active = (now - last_decrease) <= idle_secs
if not active:
time.sleep(poll)
continue
# live readout (edit-in-place, no spam)
if post_rock and not w.bot_muted(cp) and now - last_status >= status_secs:
if tleft:
empty_at = int(now + tleft)
line = (f"🪨 {last_ore.title()} {units:,} u · ~{_fmt_dur(tleft)} left "
f"(empties <t:{empty_at}:R>) · {rate*60:,.0f} u/min")
else:
line = f"🪨 {last_ore.title()} {units:,} u · measuring rate…"
w._discord_live(cp, "rock", f"⛏️ {socket.gethostname()} current rock", line)
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"{last_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 {last_ore} {units} u, ~{_fmt_dur(tleft)} left")
except Exception as e:
print(f"[rock] error: {e}")
time.sleep(poll)
def _snip_to_rock(cp):
"""Drag-select the survey window, save its box to [rock] region."""
import tkinter as tk
coords = {}
root = tk.Tk()
root.attributes("-fullscreen", True)
root.attributes("-alpha", 0.25)
root.configure(bg="black")
canvas = tk.Canvas(root, cursor="cross", bg="black", highlightthickness=0)
canvas.pack(fill=tk.BOTH, expand=True)
rect = {"id": None, "x0": 0, "y0": 0}
def on_press(e):
rect["x0"], rect["y0"] = e.x_root, e.y_root
rect["id"] = canvas.create_rectangle(e.x, e.y, e.x, e.y, outline="red", width=2)
def on_drag(e):
canvas.coords(rect["id"], rect["x0"] - root.winfo_rootx(),
rect["y0"] - root.winfo_rooty(), e.x, e.y)
def on_release(e):
x0, y0, x1, y1 = rect["x0"], rect["y0"], e.x_root, e.y_root
coords["r"] = (min(x0, x1), min(y0, y1), abs(x1 - x0), abs(y1 - y0))
root.destroy()
canvas.bind("<ButtonPress-1>", on_press)
canvas.bind("<B1-Motion>", on_drag)
canvas.bind("<ButtonRelease-1>", on_release)
root.bind("<Escape>", lambda e: root.destroy())
root.mainloop()
if "r" in coords:
l, t, wd, ht = coords["r"]
save_rock_region(cp, l, t, wd, ht)
print(f"Saved survey region {l},{t},{wd},{ht} to config.ini")
else:
print("Cancelled.")
if __name__ == "__main__":
main()