#!/usr/bin/env python3 """Gamelog watcher — everything EVE logs but has no API for. Tails EVE's local Gamelogs (Documents/EVE/logs/Gamelogs) and alerts via the same channels as the ore-hold watcher (ntfy + Windows toast + Discord). Reading the log file is legit — it never touches the game. Runs on the PC you play on. Catches: • TACKLED / EWAR'd (notify warp-scramble/disrupt/jam/web/neut) — urgent, no cooldown • Incoming damage (combat 'from') — "rats?" — cooldown'd • Hold full (notify cargo/ore hold full) • Capacitor empty (notify) • Bounty income (bounty lines) — accumulates a session total, milestone pings Config: reuses config.ini, optional [combat] section: gamelogs_dir = ; auto-detects ~/Documents/EVE/logs/Gamelogs cooldown_secs = 60 min_damage = 1 bounty_milestone = 25000000 ; ping every this much ratting ISK Run: python eve_combat_watcher.py (--test fires one alert) """ import glob import os import re import sys import time HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) import eve_orehold_watcher as w # reuse load_config() + notify() TAG = re.compile(r"<[^>]+>") INCOMING = re.compile(r"\(combat\)\s*([\d,]+)\s+from\s+(.+?)\s*[-\n]", re.IGNORECASE) DANGER = re.compile(r"\(notify\).*(warp (?:scrambl|disrupt)|unable to warp|" r"jam|target(?:ing)? (?:disrupt|jam)|energy neutraliz|web|stasis)", re.IGNORECASE) HOLDFULL = re.compile(r"\(notify\).*(cargo (?:hold )?is full|hold is full|" r"not enough (?:cargo )?space|deactivat.*full|full.*deactivat)", re.IGNORECASE) CAPEMPTY = re.compile(r"\(notify\).*(capacitor is empty|not enough (?:capacitor|energy))", re.IGNORECASE) BOUNTY = re.compile(r"\(bounty\)\s*([\d,]+(?:\.\d+)?)\s*ISK", re.IGNORECASE) # "(mining) You mined 76 units of Omber II-Grade" (after color/font tags stripped) MINING = re.compile(r"\(mining\).*?mined\s+([\d,]+)\s+units?\s+of\s+(.+?)\s*$", re.IGNORECASE) # "(notify) Successfully compressed Omber into 3029 Compressed Omber." -> hold was just freed COMPRESSED = re.compile(r"successfully compressed", re.IGNORECASE) # raw ore m³/unit (base name, longest first so 'dark ochre' beats 'ochre') 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} def ore_vol(name): n = name.strip().lower() for base in sorted(ORE_VOL, key=len, reverse=True): if base in n: return ORE_VOL[base] return 0.6 # default ~Omber def seed_raw_since_compress(path): """Sum raw ore m³ mined since the last 'Successfully compressed' in this log, so the compress-tracker is accurate immediately after a restart (not reset to zero).""" m3 = 0.0 try: lines = open(path, "r", encoding="utf-8", errors="ignore").read().splitlines() except Exception: return 0.0 for ln in lines: clean = TAG.sub("", ln) if COMPRESSED.search(clean): m3 = 0.0 # everything before a compress is gone continue mm = MINING.search(clean) if mm: m3 += int(mm.group(1).replace(",", "")) * ore_vol(mm.group(2)) return m3 def find_gamelogs(cp): d = cp.get("combat", "gamelogs_dir", fallback="").strip() if cp.has_section("combat") else "" if d and os.path.isdir(d): return d for base in (os.path.expanduser("~/Documents/EVE/logs/Gamelogs"), os.path.expanduser("~/OneDrive/Documents/EVE/logs/Gamelogs")): if os.path.isdir(base): return base return None def newest(d): files = glob.glob(os.path.join(d, "*.txt")) return max(files, key=os.path.getmtime) if files else None def main(): cp = w.load_config() g = lambda k, d: (cp.getint("combat", k, fallback=d) if cp.has_section("combat") else d) cooldown, min_dmg, milestone = g("cooldown_secs", 60), g("min_damage", 1), g("bounty_milestone", 25000000) # mining-stall = lasers stopped pulling ore (rock depleted / out of range / you stopped). # Reliable "switch rocks" signal straight from the log — no window or OCR needed. # Must exceed a normal cycle gap (~35s observed) so it doesn't false-fire between cycles. mining_stall = g("mining_stall_secs", 90) # compress reminder: ping when raw ore piled up since your last compression crosses this # many m³ (default 18,000 ≈ what Brock sits on). Tune via [combat] compress_at_m3. compress_at_m3 = g("compress_at_m3", 18000) # after this long with no ore flowing, treat the session as over and auto-pause ALL # watchers (drop a marker bot_mining() honors) so quitting never spams Discord. pause_secs = g("mining_pause_secs", 300) PAUSE_MARKER = os.path.join(w.HERE, ".mining_paused") if "--test" in sys.argv: w.notify(cp, "Gamelog watcher test", "Combat/tackle/hold/bounty alerts will reach you.", priority="high", tags="crossed_swords") return w.heartbeat(cp, "combat") # announce we're alive first gdir = find_gamelogs(cp) while not gdir: # don't die if EVE isn't running yet print("[gamelog] no Gamelogs dir yet (EVE not started?) — retrying in 30s") time.sleep(30) gdir = find_gamelogs(cp) print(f"[gamelog] watching {gdir}") cur = newest(gdir) fh = open(cur, "r", encoding="utf-8", errors="ignore") if cur else None if fh: fh.seek(0, os.SEEK_END) last_dmg = 0.0 bounty_total = 0.0 bounty_reported = 0.0 last_mine = 0.0 # last time a "You mined N units" line appeared last_ore = "" # ore on the most recent mining cycle mine_alerted = False # fired the stall alert; re-arm on the next mining line raw_m3 = seed_raw_since_compress(cur) if cur else 0.0 # uncompressed ore in hold compress_alerted = False print(f"[gamelog] raw ore since last compress ~{raw_m3:,.0f} m3 " f"(alert at {compress_at_m3:,})") while True: nl = newest(gdir) if nl and nl != cur: cur = nl if fh: fh.close() fh = open(cur, "r", encoding="utf-8", errors="ignore") bounty_total = bounty_reported = 0.0 # new session print(f"[gamelog] -> {os.path.basename(cur)}") if not fh: time.sleep(3); continue line = fh.readline() if not line: # no new log line — check if mining has stalled (lasers stopped → switch rocks) if last_mine and not mine_alerted and w.bot_mining(cp) and not w.bot_muted(cp) \ and time.time() - last_mine > mining_stall: mine_alerted = True ore = (last_ore.strip().title() + " ") if last_ore else "" w.notify(cp, "Switch rocks", f"Lasers stopped pulling {ore}ore " f"({int(time.time()-last_mine)}s) — rock depleted or out of range. " "Lock a new rock.", priority="high", tags="pick") print("[gamelog] mining stall — switch-rocks alert") # stopped for good (you quit / warped off)? auto-pause every watcher so the rest # of the session is silent until ore flows again. if last_mine and time.time() - last_mine > pause_secs \ and not os.path.exists(PAUSE_MARKER): open(PAUSE_MARKER, "w").close() print("[gamelog] mining idle — auto-paused watchers") time.sleep(1); continue clean = TAG.sub("", line) mm = MINING.search(clean) if mm: last_mine = time.time() last_ore = mm.group(2) mine_alerted = False # re-arm: lasers are pulling again if os.path.exists(PAUSE_MARKER): # ore flowing -> resume all watchers try: os.remove(PAUSE_MARKER) print("[gamelog] mining resumed — watchers un-paused") except OSError: pass raw_m3 += int(mm.group(1).replace(",", "")) * ore_vol(mm.group(2)) if raw_m3 >= compress_at_m3 and not compress_alerted \ and w.bot_mining(cp) and not w.bot_muted(cp): compress_alerted = True w.notify(cp, "Compress now", f"~{raw_m3:,.0f} m³ of raw ore piled up since your last " f"compression — run the compressor to free the hold.", priority="high", tags="package") print(f"[gamelog] compress alert at {raw_m3:,.0f} m3") continue if COMPRESSED.search(clean): # you compressed -> hold freed, re-arm raw_m3 = 0.0 compress_alerted = False continue if "(notify)" in line: if DANGER.search(clean): w.notify(cp, "⚠ TACKLED / EWAR", "Scrambled/jammed/webbed — you may not be able to warp. ACT NOW.", priority="urgent", tags="rotating_light"); continue if HOLDFULL.search(clean): w.notify(cp, "Hold full", "Your hold is full — compress / unload / swap.", priority="high", tags="package"); continue if CAPEMPTY.search(clean): w.notify(cp, "Capacitor empty", "Cap's out — modules dropping.", priority="default", tags="battery"); continue continue if "(bounty)" in line: mb = BOUNTY.search(clean) if mb: bounty_total += float(mb.group(1).replace(",", "")) if bounty_total - bounty_reported >= milestone: bounty_reported = bounty_total w.notify(cp, "Ratting income", f"~{bounty_total/1e6:.0f}M ISK in bounties this session.", priority="low", tags="moneybag") continue if "(combat)" in line: m = INCOMING.search(clean) if not m: continue dmg = int(m.group(1).replace(",", "")) if dmg < min_dmg or time.time() - last_dmg < cooldown: continue last_dmg = time.time() w.notify(cp, "Taking damage — rats?", f"Incoming fire from {m.group(2).strip()} ({dmg}). Engage drones, " f"watch shield.", priority="urgent", tags="crossed_swords") print(f"[gamelog] damage {dmg} from {m.group(2).strip()}") if __name__ == "__main__": main()