commit 218165580137c86bf83990a067f74276e7796812 Author: bionickatana Date: Mon Aug 10 21:33:00 2026 -0600 Test files and initial data gathering completed diff --git a/__pycache__/analyze.cpython-314.pyc b/__pycache__/analyze.cpython-314.pyc new file mode 100644 index 0000000..a1a6d38 Binary files /dev/null and b/__pycache__/analyze.cpython-314.pyc differ diff --git a/__pycache__/capture.cpython-314.pyc b/__pycache__/capture.cpython-314.pyc new file mode 100644 index 0000000..186fa2e Binary files /dev/null and b/__pycache__/capture.cpython-314.pyc differ diff --git a/analysis.jsonl b/analysis.jsonl new file mode 100644 index 0000000..cc23f9d --- /dev/null +++ b/analysis.jsonl @@ -0,0 +1,46 @@ +{"time": "21:22:12", "frame": 0, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 204, "action_points": 140}} +{"time": "21:22:13", "frame": 1, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 204, "action_points": 140}} +{"time": "21:22:14", "frame": 2, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 204, "action_points": 140}} +{"time": "21:22:16", "frame": 3, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 203, "action_points": 140}} +{"time": "21:22:17", "frame": 4, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 202, "action_points": 140}} +{"time": "21:22:18", "frame": 5, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 202, "action_points": 140}} +{"time": "21:22:20", "frame": 6, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 201, "action_points": 140}} +{"time": "21:22:21", "frame": 7, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 201, "action_points": 140}} +{"time": "21:22:22", "frame": 8, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 200, "action_points": 140}} +{"time": "21:22:23", "frame": 9, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 200, "action_points": 140}} +{"time": "21:22:24", "frame": 10, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 200, "action_points": 140}} +{"time": "21:22:26", "frame": 11, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 199, "action_points": 140}} +{"time": "21:22:27", "frame": 12, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 198, "action_points": 140}} +{"time": "21:22:28", "frame": 13, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 198, "action_points": 140}} +{"time": "21:22:29", "frame": 14, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 1971, "action_points": 140}} +{"time": "21:22:30", "frame": 15, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 1971, "action_points": 140}} +{"time": "21:22:32", "frame": 16, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 196, "action_points": 140}} +{"time": "21:22:33", "frame": 17, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 196, "action_points": 140}} +{"time": "21:22:34", "frame": 18, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 196, "action_points": 140}} +{"time": "21:22:36", "frame": 19, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 195, "action_points": 140}} +{"time": "21:22:37", "frame": 20, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 194, "action_points": 140}} +{"time": "21:22:38", "frame": 21, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 194, "action_points": 140}} +{"time": "21:22:40", "frame": 22, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 193, "action_points": 140}} +{"time": "21:22:41", "frame": 23, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 193, "action_points": 140}} +{"time": "21:22:43", "frame": 24, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 1921, "action_points": 140}} +{"time": "21:22:44", "frame": 25, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 1921, "action_points": 140}} +{"time": "21:22:46", "frame": 26, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 1911, "action_points": 140}} +{"time": "21:22:47", "frame": 27, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 190, "action_points": 140}} +{"time": "21:22:49", "frame": 28, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 190, "action_points": 140}} +{"time": "21:22:50", "frame": 29, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 189, "action_points": 140}} +{"time": "21:22:52", "frame": 30, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 189, "action_points": 140}} +{"time": "21:22:53", "frame": 31, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 188, "action_points": 140}} +{"time": "21:22:55", "frame": 32, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 188, "action_points": 140}} +{"time": "21:22:56", "frame": 33, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 1871, "action_points": 140}} +{"time": "21:22:58", "frame": 34, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": null, "health": 40, "inventory": 1871, "action_points": 140}} +{"time": "21:22:59", "frame": 35, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": null, "health": 40, "inventory": 186, "action_points": 140}} +{"time": "21:23:01", "frame": 36, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": null, "health": 40, "inventory": 185, "action_points": 140}} +{"time": "21:23:02", "frame": 37, "is_harvesting": false, "harvest_pixel": "#664c33", "harvest_luma": 81, "bars": {"mana": 32, "food": null, "health": 40, "inventory": 185, "action_points": 140}} +{"time": "21:23:04", "frame": 38, "is_harvesting": false, "harvest_pixel": "#664c33", "harvest_luma": 81, "bars": {"mana": 32, "food": null, "health": 40, "inventory": 185, "action_points": 140}} +{"time": "21:23:05", "frame": 39, "is_harvesting": false, "harvest_pixel": "#664c33", "harvest_luma": 81, "bars": {"mana": 32, "food": null, "health": 40, "inventory": 185, "action_points": 140}} +{"time": "21:23:07", "frame": 40, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": null, "health": 40, "inventory": 185, "action_points": 140}} +{"time": "21:23:08", "frame": 41, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": null, "health": 40, "inventory": 185, "action_points": 140}} +{"time": "21:23:10", "frame": 42, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": null, "health": 40, "inventory": 185, "action_points": 140}} +{"time": "21:23:12", "frame": 43, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": null, "health": 40, "inventory": 184, "action_points": 140}} +{"time": "21:23:13", "frame": 44, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": null, "health": 40, "inventory": 184, "action_points": 140}} +{"time": "21:23:15", "frame": 45, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": null, "health": 40, "inventory": 183, "action_points": 140}} diff --git a/analyze.py b/analyze.py new file mode 100644 index 0000000..5ac27df --- /dev/null +++ b/analyze.py @@ -0,0 +1,127 @@ +"""Lightweight image analysis for live capture feedback. + +All stats are computed on a small downsampled copy so the demo loop stays +fast at full 1080p capture. +""" + +from collections import Counter + +from PIL import Image + +ANALYSIS_SIZE = (320, 180) +_TOP_COLORS = 5 + +# Harvesting is detected from the single pixel at this window coordinate. +# Harvesting shows a light tan (~#f2c285, luma ~201); idle shows dark brown +# (~#664c33, luma ~81). +HARVEST_PIXEL = (1710, 1068) +HARVEST_LUMA_THRESHOLD = 140 + +# The small numeric readout just before each bar at the bottom of the panel. +# Regions are (x0, y0, x1, y1) in captured-window pixel coordinates. y0 starts +# below the panel's top border line (y=1032) which would confuse OCR. +NUMBER_REGIONS = { + "mana": (0, 1033, 62, 1048), + "food": (195, 1033, 226, 1048), + "health": (355, 1033, 389, 1048), + "inventory": (520, 1033, 552, 1048), + "action_points": (610, 1033, 714, 1048), +} + +_OCR_SCALE = 3 + +try: + import pytesseract +except ImportError: + pytesseract = None + + +def _luma(pixel): + r, g, b = pixel + return round(0.299 * r + 0.587 * g + 0.114 * b) + + +def load_image(path): + """Load a PNG as RGB; return None if the file is unreadable/corrupt.""" + try: + return Image.open(path).convert("RGB") + except OSError: + return None + + +def is_harvesting(image): + """Return whether the player is harvesting, based on the harvest pixel.""" + rgb = image.convert("RGB") + color = rgb.getpixel(HARVEST_PIXEL) + lum = _luma(color) + return { + "is_harvesting": lum >= HARVEST_LUMA_THRESHOLD, + "color": "#%02x%02x%02x" % color, + "luma": lum, + } + + +def _ocr_number(image, region): + """OCR a single number region; return int or None if it can't be read.""" + if pytesseract is None: + return None + crop = image.convert("L").crop(region) + crop = crop.resize((crop.width * _OCR_SCALE, crop.height * _OCR_SCALE)) + crop = crop.point(lambda p: 0 if p < 140 else 255) + try: + text = pytesseract.image_to_string( + crop, + config="--psm 7 -c tessedit_char_whitelist=0123456789", + ) + except Exception: + return None + digits = "".join(ch for ch in text if ch.isdigit()) + return int(digits) if digits else None + + +def read_bar_values(image): + """Return the numeric readout before each bar, or None if unreadable.""" + rgb = image.convert("RGB") + return { + name: _ocr_number(rgb, region) + for name, region in NUMBER_REGIONS.items() + } + + +def _pixels(image): + px = image.load() + w, h = image.size + return [px[x, y] for y in range(h) for x in range(w)] + + +def summarize(image): + """Return size, mean RGB, and dominant colors of a captured frame.""" + small = image.convert("RGB").resize(ANALYSIS_SIZE) + pixels = _pixels(small) + n = len(pixels) + total = [0, 0, 0] + for r, g, b in pixels: + total[0] += r + total[1] += g + total[2] += b + mean = tuple(round(c / n) for c in total) + top = Counter(pixels).most_common(_TOP_COLORS) + dominant = [ + {"hex": "#%02x%02x%02x" % color, "fraction": round(count / n, 3)} + for color, count in top + ] + return {"size": (image.width, image.height), "mean_rgb": mean, "dominant": dominant} + + +def difference(a, b): + """Sum of absolute channel differences between two same-size RGB images.""" + if a.size != b.size: + return None + pa, pb = a.load(), b.load() + w, h = a.size + diff = 0 + for y in range(h): + for x in range(w): + ca, cb = pa[x, y], pb[x, y] + diff += abs(ca[0] - cb[0]) + abs(ca[1] - cb[1]) + abs(ca[2] - cb[2]) + return diff diff --git a/capture.py b/capture.py new file mode 100644 index 0000000..6b5484a --- /dev/null +++ b/capture.py @@ -0,0 +1,86 @@ +"""X11 window capture for Eternal Lands analysis. + +Captures a specific window's pixels even when it is not focused. On X11, +focus only affects input routing, never pixel retrieval: XGetImage reads the +window's own pixmap. Cinnamon's compositor keeps the redirected content +available, so this works regardless of focus or occlusion. + +If window-level capture is unavailable (e.g. compositing is disabled), we +fall back to grabbing the visible region from the root window at the +window's screen coordinates. +""" + +import re + +from PIL import Image + +from Xlib import X, display +from Xlib.error import XError + + +class WindowNotFound(Exception): + """Raised when no window matching the title pattern is found.""" + + +class WindowCapture: + def __init__(self, title_pattern, display_name=None): + self.title_pattern = re.compile(title_pattern, re.IGNORECASE) + self.display = display.Display(display_name) + self.root = self.display.screen().root + self.window = None + + def find_window(self): + """Return the cached or freshly-searched X window for the title.""" + if self.window is not None: + try: + self.window.get_geometry() + return self.window + except XError: + self.window = None + self.window = self._search(self.root) + return self.window + + def _search(self, window, depth=0): + if depth > 24: + return None + try: + name = window.get_wm_name() + except XError: + name = None + if name and self.title_pattern.search(name): + try: + window.get_geometry() + return window + except XError: + return None + try: + children = window.query_tree().children + except XError: + return None + for child in children: + found = self._search(child, depth + 1) + if found: + return found + return None + + def capture(self): + """Capture the target window and return a PIL.Image.""" + window = self.find_window() + if window is None: + raise WindowNotFound( + f"no window matching {self.title_pattern.pattern!r} " + f"on display {self.display.display_string}" + ) + geo = window.get_geometry() + try: + ximage = window.get_image(0, 0, geo.width, geo.height, X.ZPixmap, 0xFFFFFFFF) + except XError: + coords = self.root.translate_coords(window, 0, 0) + ximage = self.root.get_image( + coords.x, coords.y, geo.width, geo.height, X.ZPixmap, 0xFFFFFFFF + ) + return _to_image(ximage, geo.width, geo.height) + + +def _to_image(ximage, width, height): + return Image.frombytes("RGBA", (width, height), ximage.data, "raw", "BGRA") diff --git a/main.py b/main.py new file mode 100644 index 0000000..fc9dac7 --- /dev/null +++ b/main.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Demo: capture the Eternal Lands window, analyze it, and log results as JSONL. + +Usage: + python3 main.py [--title "Eternal Lands"] [--fps 5] [--save-every 0] [--log analysis.jsonl] + python3 main.py --replay tests/images/harvesting [--log analysis.jsonl] +""" + +import argparse +import json +import os +import time + +import analyze +from capture import WindowCapture, WindowNotFound + + +def analyze_and_print(image, frame, prev, logf): + global _last_t + info = analyze.summarize(image) + harvest = analyze.is_harvesting(image) + bars = analyze.read_bar_values(image) + fps = 1.0 / max(1e-6, time.monotonic() - _last_t) + _last_t = time.monotonic() + + record = { + "time": _now(), + "frame": frame, + "is_harvesting": harvest["is_harvesting"], + "harvest_pixel": harvest["color"], + "harvest_luma": harvest["luma"], + "bars": bars, + } + if logf is not None: + logf.write(json.dumps(record) + "\n") + logf.flush() + + small = image.convert("RGB").resize(analyze.ANALYSIS_SIZE) + delta = None if prev is None else analyze.difference(prev, small) + + top = ", ".join(f"{c['hex']} {c['fraction'] * 100:.0f}%" for c in info["dominant"][:3]) + status = "harvesting" if harvest["is_harvesting"] else "not harvesting" + frame_str = f"{frame:05d}" if isinstance(frame, int) else frame + print( + f"[{frame_str}] {status} luma={harvest['luma']} " + f"size={info['size'][0]}x{info['size'][1]} " + f"mean={info['mean_rgb']} top=[{top}] bars={bars} " + f"delta={delta} loop_fps={fps:.1f}", + flush=True, + ) + return small + + +def run_replay(args): + """Process PNGs from a directory instead of a live window.""" + logf = open(args.log, "a") if args.log else None + prev = None + try: + for name in sorted(os.listdir(args.replay)): + if not name.lower().endswith(".png"): + continue + path = os.path.join(args.replay, name) + image = analyze.load_image(path) + if image is None: + print(f"[skipped] unreadable image: {path}", flush=True) + continue + prev = analyze_and_print(image, name, prev, logf) + finally: + if logf: + logf.close() + + +def run_live(args): + cap = WindowCapture(args.title) + os.makedirs(args.snapshots, exist_ok=True) + interval = 1.0 / args.fps + + print(f"Capturing window matching {args.title!r} at {args.fps:.1f} fps (Ctrl-C to stop)", flush=True) + logf = open(args.log, "a") if args.log else None + prev = None + frame = 0 + try: + while True: + started = time.monotonic() + try: + image = cap.capture() + except WindowNotFound as exc: + print(f"[{_now()}] {exc}; retrying...", flush=True) + time.sleep(1.0) + continue + prev = analyze_and_print(image, frame, prev, logf) + + if args.save_every and frame % args.save_every == 0: + path = os.path.join(args.snapshots, f"frame_{frame:05d}.png") + image.save(path) + + frame += 1 + elapsed = time.monotonic() - started + time.sleep(max(0.0, interval - elapsed)) + finally: + if logf: + logf.close() + + +def main(): + parser = argparse.ArgumentParser(description="Capture and analyze a specific window on X11") + parser.add_argument( + "--title", default="Eternal Lands", + help="window title to match (substring, case-insensitive)", + ) + parser.add_argument("--fps", type=float, default=5.0, help="target capture rate") + parser.add_argument("--snapshots", default="snapshots", help="directory for PNG snapshots") + parser.add_argument( + "--save-every", type=int, default=0, metavar="N", + help="save a snapshot every N frames (0 = off)", + ) + parser.add_argument( + "--log", default="analysis.jsonl", metavar="PATH", + help="append one JSON record per frame to PATH (empty string disables)", + ) + parser.add_argument( + "--replay", metavar="DIR", + help="process existing PNGs from DIR instead of capturing a window", + ) + args = parser.parse_args() + + if args.replay: + run_replay(args) + else: + run_live(args) + + +_last_t = time.monotonic() + + +def _now(): + return time.strftime("%H:%M:%S") + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\nstopped") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3548045 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +python-xlib>=0.33 +pillow>=10 +pytesseract>=0.3 +pytest>=8 diff --git a/tests/__pycache__/test_bars.cpython-314-pytest-9.1.1.pyc b/tests/__pycache__/test_bars.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000..a26e0a7 Binary files /dev/null and b/tests/__pycache__/test_bars.cpython-314-pytest-9.1.1.pyc differ diff --git a/tests/__pycache__/test_corrupt.cpython-314-pytest-9.1.1.pyc b/tests/__pycache__/test_corrupt.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000..8eeba97 Binary files /dev/null and b/tests/__pycache__/test_corrupt.cpython-314-pytest-9.1.1.pyc differ diff --git a/tests/__pycache__/test_harvesting.cpython-314-pytest-9.1.1.pyc b/tests/__pycache__/test_harvesting.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000..d52b8f3 Binary files /dev/null and b/tests/__pycache__/test_harvesting.cpython-314-pytest-9.1.1.pyc differ diff --git a/tests/images/corrupt/frame_00014.png b/tests/images/corrupt/frame_00014.png new file mode 100644 index 0000000..80cbd38 Binary files /dev/null and b/tests/images/corrupt/frame_00014.png differ diff --git a/tests/images/harvesting/frame_00000.png b/tests/images/harvesting/frame_00000.png new file mode 100644 index 0000000..1c6dc67 Binary files /dev/null and b/tests/images/harvesting/frame_00000.png differ diff --git a/tests/images/harvesting/frame_00001.png b/tests/images/harvesting/frame_00001.png new file mode 100644 index 0000000..2552fa4 Binary files /dev/null and b/tests/images/harvesting/frame_00001.png differ diff --git a/tests/images/harvesting/frame_00002.png b/tests/images/harvesting/frame_00002.png new file mode 100644 index 0000000..d1c5119 Binary files /dev/null and b/tests/images/harvesting/frame_00002.png differ diff --git a/tests/images/harvesting/frame_00003.png b/tests/images/harvesting/frame_00003.png new file mode 100644 index 0000000..676f688 Binary files /dev/null and b/tests/images/harvesting/frame_00003.png differ diff --git a/tests/images/harvesting/frame_00004.png b/tests/images/harvesting/frame_00004.png new file mode 100644 index 0000000..f3302ab Binary files /dev/null and b/tests/images/harvesting/frame_00004.png differ diff --git a/tests/images/harvesting/frame_00011.png b/tests/images/harvesting/frame_00011.png new file mode 100644 index 0000000..48118b5 Binary files /dev/null and b/tests/images/harvesting/frame_00011.png differ diff --git a/tests/images/harvesting/frame_00012.png b/tests/images/harvesting/frame_00012.png new file mode 100644 index 0000000..2524fb5 Binary files /dev/null and b/tests/images/harvesting/frame_00012.png differ diff --git a/tests/images/harvesting/frame_00013.png b/tests/images/harvesting/frame_00013.png new file mode 100644 index 0000000..41f19f5 Binary files /dev/null and b/tests/images/harvesting/frame_00013.png differ diff --git a/tests/images/not_harvesting/frame_00005.png b/tests/images/not_harvesting/frame_00005.png new file mode 100644 index 0000000..83e0e1f Binary files /dev/null and b/tests/images/not_harvesting/frame_00005.png differ diff --git a/tests/images/not_harvesting/frame_00006.png b/tests/images/not_harvesting/frame_00006.png new file mode 100644 index 0000000..3f5c38c Binary files /dev/null and b/tests/images/not_harvesting/frame_00006.png differ diff --git a/tests/images/not_harvesting/frame_00007.png b/tests/images/not_harvesting/frame_00007.png new file mode 100644 index 0000000..60fc71b Binary files /dev/null and b/tests/images/not_harvesting/frame_00007.png differ diff --git a/tests/images/not_harvesting/frame_00008.png b/tests/images/not_harvesting/frame_00008.png new file mode 100644 index 0000000..bcfbae9 Binary files /dev/null and b/tests/images/not_harvesting/frame_00008.png differ diff --git a/tests/images/not_harvesting/frame_00009.png b/tests/images/not_harvesting/frame_00009.png new file mode 100644 index 0000000..37adaef Binary files /dev/null and b/tests/images/not_harvesting/frame_00009.png differ diff --git a/tests/images/not_harvesting/frame_00010.png b/tests/images/not_harvesting/frame_00010.png new file mode 100644 index 0000000..9198612 Binary files /dev/null and b/tests/images/not_harvesting/frame_00010.png differ diff --git a/tests/test_bars.py b/tests/test_bars.py new file mode 100644 index 0000000..89e52ad --- /dev/null +++ b/tests/test_bars.py @@ -0,0 +1,70 @@ +import shutil +from pathlib import Path + +import pytest + +import analyze + +ALL_IMAGES = ( + sorted(Path("tests/images/harvesting").glob("*.png")) + + sorted(Path("tests/images/not_harvesting").glob("*.png")) +) + +assert len(ALL_IMAGES) > 0, "no images found under tests/images" + +# Inventory capacity was observed to change as the player collects items. +# Frames not listed here are expected to read 106. +INVENTORY_EXPECTED = { + "frame_00000.png": 110, + "frame_00001.png": 110, + "frame_00002.png": 108, + "frame_00003.png": 108, +} + +OCR_AVAILABLE = analyze.pytesseract is not None and shutil.which("tesseract") is not None + +pytestmark = pytest.mark.skipif( + not OCR_AVAILABLE, + reason="pytesseract or tesseract-ocr not installed", +) + + +@pytest.fixture(scope="session") +def bar_values(): + values = {} + for path in ALL_IMAGES: + image = analyze.load_image(path) + assert image is not None, f"cannot load {path}" + values[path.name] = analyze.read_bar_values(image) + return values + + +def test_mana_is_32(bar_values): + for name, values in bar_values.items(): + assert values["mana"] == 32, name + + +def test_food_is_39(bar_values): + for name, values in bar_values.items(): + assert values["food"] == 39, name + + +def test_health_is_40(bar_values): + for name, values in bar_values.items(): + assert values["health"] == 40, name + + +def test_action_points_are_140(bar_values): + for name, values in bar_values.items(): + assert values["action_points"] == 140, name + + +def test_inventory_matches_expected(bar_values): + for name, values in bar_values.items(): + expected = INVENTORY_EXPECTED.get(name, 106) + assert values["inventory"] == expected, name + + +def test_every_bar_has_a_value(bar_values): + for name, values in bar_values.items(): + assert all(v is not None for v in values.values()), name diff --git a/tests/test_corrupt.py b/tests/test_corrupt.py new file mode 100644 index 0000000..c8b2ff4 --- /dev/null +++ b/tests/test_corrupt.py @@ -0,0 +1,10 @@ +from pathlib import Path + +import analyze + +CORRUPT = sorted(Path("tests/images/corrupt").glob("*.png")) + + +def test_corrupt_images_are_skipped_gracefully(): + for path in CORRUPT: + assert analyze.load_image(path) is None, path diff --git a/tests/test_harvesting.py b/tests/test_harvesting.py new file mode 100644 index 0000000..9395e9c --- /dev/null +++ b/tests/test_harvesting.py @@ -0,0 +1,23 @@ +from pathlib import Path + +import analyze + +HARVESTING = sorted(Path("tests/images/harvesting").glob("*.png")) +NOT_HARVESTING = sorted(Path("tests/images/not_harvesting").glob("*.png")) + +assert len(HARVESTING) > 0, "no images found in tests/images/harvesting" +assert len(NOT_HARVESTING) > 0, "no images found in tests/images/not_harvesting" + + +def test_harvesting_frames_report_harvesting(): + for path in HARVESTING: + image = analyze.load_image(path) + assert image is not None, f"cannot load {path}" + assert analyze.is_harvesting(image)["is_harvesting"] is True, path + + +def test_not_harvesting_frames_report_not_harvesting(): + for path in NOT_HARVESTING: + image = analyze.load_image(path) + assert image is not None, f"cannot load {path}" + assert analyze.is_harvesting(image)["is_harvesting"] is False, path