Gorgon Monitorv1.5.0

A small program you leave running while you play. It keeps this site honest about your world.

It only reads Project Gorgon’s own log folder. Nothing else on your computer.

It opens Player.log and the ChatLogs folder, which the game writes as you play, and the Books folder, where the game puts any book you save. From those it sends only the specific reports listed below. It never uploads a file, and it never sends your chat, your inventory or anything about your account.

The only things it writes are two small notes files beside itself, so it remembers what it already reported, and a copy of every book you open, saved into the game’s own Books folder exactly as the in-game save button would. Those stay on your machine: a saved book is never uploaded. Every line of it is printed on this page, so you do not have to take this box’s word for it.

Project Gorgon keeps a few plain text files on your own hard drive. As you play, it logs the NPCs you talk to, what dropped off the thing you just killed, and the chat around you. When you save a book in game, such as Today’s Weather or the Gardening Almanac, it writes that book out as a file too. This program reads those files, picks out the handful of things the whole community benefits from knowing, and posts those here: weather, gardens, the blood season, the daily missions, what creatures actually drop.

None of that can come from the game's data files. It has to be seen by somebody who was there. A dozen people running this while they play is what keeps the Clock, Weather, Garden and Bestiary pages worth opening.

2,753 lines, 140 KB, Python 3.9+, no dependencies
The whole thing is printed further down this page. Read it before you run it.

It is one file, it installs nothing, and it needs no key or account. Stop it any time by closing its window.

What it reports

All of it is public world knowledge, the sort of thing players shout in global chat. Each line below feeds the page next to it.

WeatherToday's Weather book, per zone. Feeds Weather, and the vampire sun-safety warnings.
GardensThe Gardening Almanac's growing seasons and when they end. Feeds Garden.
Warden alertsThe Active Warden Alerts book. Feeds Clock and the daily chat commands.
Aurest missionLady Alethina's mission for the day.
Agrashab's bountyThe Fae Realm god's daily group bounty.
Blood seasonThe day's vampire blood target. Feeds Blood Guide.
Casino tips and fightsKuzavek's hot tips and Kyuroma's current matchup. Feeds Gambling.
Arena resultsWhether the fighter you backed won or lost. Never how much you wagered. Win/loss ratios need the result and nothing else.
Mob dropsWhat creatures dropped, and where. Feeds Bestiary. This is the one nobody can do alone: a drop table takes hundreds of kills to see, and everybody is killing things anyway.
Death XPHow much XP each cause of death awards, and the ladder of smaller awards it pays on repeat deaths. Fills the blanks on Dying. This one cannot come from the game's data files at all: somebody has to die of the thing and read the number, and nobody dies of everything.

Your character name travels with the mob-drop and arena reports, so that nonsense reports can be spotted and thrown out. That is the only thing about you that is sent.

What it never sends

Not one byte of any of this leaves your PC:

Every report is built from one specific pattern the program goes looking for. Everything else in the log is read past and forgotten. And it only ever reads the game's files, so it cannot damage a character or do anything the game would object to. It is not a mod, an add-on or an overlay, and the game never knows it is there.

Two things it saves for you, locally

Running it

  1. Get Python. Version 3.9 or newer, from python.org. On the Windows installer, tick Add Python to PATH. Nothing else is needed: no pip install, no extra libraries.
  2. Save the file anywhere you like, using the download button above.
  3. Open a terminal in that folder and run it.
    Windows:   py gorgon_monitor.py
    Mac/Linux: python3 gorgon_monitor.py
    On Windows you can also right-click the folder and choose Open in Terminal.
  4. Play. It opens with a summary: the version, your world, the game folder it found, and what it will report. Then leave the window open in the background; it prints a line each time it reports something. Press Ctrl-C in that window to stop it, and nothing is left running afterwards.

Which world you are on is read out of the game's own log, so you do not have to say. A world this site does not track, the public beta for instance, is recognised and reporting simply switches itself off.

Options

py gorgon_monitor.py --help                  every option
py gorgon_monitor.py --version               which build you have
py gorgon_monitor.py --game-dir "C:\path"     name your game folder yourself
py gorgon_monitor.py --game-server dreva     skip world detection
py gorgon_monitor.py --save-books off        do not save any books for me

If something looks wrong

The source

The whole program, exactly as you would download it. Nothing is minified, obfuscated or hidden, and the comments are written to be read by somebody who does not write code. If a claim above matters to you, this is where you check it.

PART 1 Talking to the website
PART 2 Reading the game's books
PART 3 Settings (the paths you might edit)
PART 4 Patterns (every log line it looks at)
PART 5 The monitor itself
PART 6 Command line

PART 4 is the part to read if you are suspicious. Every pattern there quotes a real line from a real log, so you can open your own Player.log in Notepad and see precisely what this program is allowed to notice.

gorgon_monitor.py, v1.5.0

#!/usr/bin/env python3
"""
Gorgon Monitor | the Gorgonzola community reporter for Project Gorgon
=======================================================================

WHAT THIS IS
  A small program you leave running while you play. It reads the log files the
  game already writes on your own PC, notices a handful of things the whole
  community benefits from knowing (today's weather, the blood season, what a
  creature dropped) and posts those to the community site:

      https://gorgonzola.spellboundtavern.com

  It never touches the game itself. It cannot press keys, move your character,
  read your screen or talk to the game server. It only reads files, the same way
  Notepad would. That is the whole trick: Project Gorgon writes plain text logs of
  what you saw, plus a file for any book you save, and this reads them.

  You do not need to understand any of the code below to use it. Scroll to
  "HOW TO RUN" and you are done. The comments are there for the curious, and so
  that anybody can check for themselves that it does what this header claims.

WHAT IT SENDS TO THE SITE
  All of this is public world knowledge, the sort of thing players shout in global
  chat. Never anything private about you or your account.

    - Weather and Garden        The in-game "Today's Weather" and "Gardening
                                Almanac" books, so the site's live boards match
                                the world you are actually playing on.
    - Warden alerts             The "Active Warden Alerts" book.
    - Aurest mission            Lady Alethina's daily mission.
    - Agrashab's bounty         The Fae Realm god's daily group bounty.
    - Blood target              The day's vampire blood season.
    - Casino tips and fights    Kuzavek's hot tips and Kyuroma's current matchup,
                                for the community gambling page.
    - Arena bet results         Whether the fighter you backed won or lost. The
                                WAGER AMOUNT IS NEVER SENT. Only who fought and
                                who won, which is what fighter win/loss ratios
                                are made of.
    - Mob drops                 What creatures dropped, for the community
                                bestiary. This is the big one: nobody can map a
                                drop table alone, and a few dozen players playing
                                normally will map the whole game.
    - Death XP                  How much XP each cause of death awards, and the whole
                                ladder of repeat awards it pays after that, filling in
                                the blanks on the Dying page. Nobody dies of
                                everything, which is exactly why this is pooled.

  Your character's name rides along with the mob-drop and arena-bet reports, so
  duplicate or nonsense reports can be spotted and dropped. That is the only
  thing about you that is sent.

WHAT IT NEVER SENDS
  Not your chat, not your whispers, not your guild chat, not your inventory, not
  your councils, not your account, not your password, not your IP beyond the
  ordinary fact of making a web request, and no file from your computer. Nothing
  is uploaded wholesale: every report above is built from a specific pattern this
  program looks for, and everything else in the logs is ignored and forgotten.

  It is also read-only against the game. It opens the game's log files for
  reading and nothing else, so it cannot corrupt a character or trip anything the
  game would object to.

WHAT IT DOES FOR YOU IN RETURN
  - Saves work-order books to your Books folder by itself, so you can skip the
    in-game "save this book" click. Gorgonzola's Shop P&L page reads those files
    to book what a work order cost you.
  - Keeps a local research file (deaths_observed.json) of the death XP you have
    personally seen, next to this script.
  - Keeps the site's live boards correct for YOUR world, which is what makes the
    clock, weather, garden and daily pages worth opening.

WHAT YOU NEED
  - Python 3.9 or newer. Nothing else: no "pip install", no accounts, no API key,
    no configuration. One file, standard library only.
  - Project Gorgon installed. Its folder is found for you on Windows, WSL, macOS,
    Linux and Steam Proton. If yours is somewhere unusual, name it once with
    --game-dir and nothing else needs changing.
  - Nothing else is required. If the files quests.json / deaths.json happen to be
    sitting next to this script it will use them for nicer names, and it works
    perfectly well without them.

HOW TO RUN
  Save this file somewhere, open a terminal in that folder, and run:

      python3 gorgon_monitor.py

  On Windows that is usually:

      py gorgon_monitor.py

  It opens with a summary: the version, the world you are on, the game folder it
  found, and the list of what it will report. Read that, then play. Leave the window
  open in the background; it prints a line whenever it reports something. Press
  Ctrl-C in that window to stop it, and nothing is left running afterwards.

  Useful extras:

      python3 gorgon_monitor.py --game-dir "path"     Name your game folder
      python3 gorgon_monitor.py --game-server dreva   Skip world auto-detection
      python3 gorgon_monitor.py --save-books off      Do not auto-save books
      python3 gorgon_monitor.py --version             Print the version
      python3 gorgon_monitor.py --help                List every option

  Which world you are on is detected from the game's own log, so you normally do
  not have to say. A world the site does not track (the public beta, for
  instance) is detected and world reporting simply switches itself off.

IF SOMETHING LOOKS WRONG
  - "Could not find your Project Gorgon folder": it prints every place it looked.
    Point it at yours with --game-dir "path/to/Project Gorgon". It will not run on
    regardless, because a monitor that reports nothing looks exactly like a quiet
    evening in game.
  - Nothing is ever printed: it only reports what happens FROM NOW ON, and it
    only reports things that are worth reporting. Read a weather book in game and
    you should see a line within about ten seconds.
  - "not a world this site tracks": you are on beta or a private world. Expected;
    everything else keeps working.
  - A "429" or "rate limited" message: somebody already reported that, seconds
    ago. Harmless, and it backs off on its own.

OPTIONAL EXTRAS (off unless you set the matching environment variable)
  - redwing.bet mirror. Set GORGON_KEY (hot tips) and/or LIVE_REPORT_SECRET (live
    fights) to forward casino data to redwing.bet as well. Left unset, as it is for
    everyone by default, that code never runs.

HOW THIS FILE IS LAID OUT
  If you do want to read the code, it goes in this order:

      PART 1  Talking to the website        (one small POST helper)
      PART 2  Reading the game's books      (weather / almanac text -> data)
      PART 3  Settings                      (finding your game, and tuning knobs)
      PART 4  Patterns                      (what a log line looks like)
      PART 5  The monitor                   (the loop, and one method per thing
                                             it watches for)
      PART 6  Command line                  (options, and the starting point)

  Every pattern in PART 4 is a real line seen in a real log, quoted in the
  comment above it, so you can check the claim against your own Player.log.

VERSION
  1.5.0  (2026-09-17)  Mob drops now say which world they came from, and when they
                       were seen. Both are for one new thing: vampire loot names the
                       day's blood target, so a creature that pays out Lifeblood
                       during a season carries that blood, even where the site's
                       bestiary guessed otherwise. Nothing new is read and nothing
                       new is sent about you.
  1.4.0  (2026-09-15)  Saves every book you open, not a curated three. Opening a book
                       is the only signal there is that you wanted it, and the weather,
                       garden and warden reports are all built from a saved book file,
                       so the old default let you read the almanac in game and report
                       nothing. Saved books stay on your disk, as they always have.
                       Narrow it with --save-books if you want the old behaviour.
  1.3.1  (2026-09-15)  Wording only. The header called the game's files a "diary",
                       which blurred two different things: logs the game writes as
                       you play, and a book file that exists only because you saved
                       that book. Nothing it reads or sends has changed.
  1.3.0  (2026-09-08)  Death XP actually uploads. The site was refusing every copy
                       but the author's, so a shared monitor recorded its findings
                       to disk and nowhere else. The full set of award values now
                       goes up too, not only the highest.
  1.2.0  (2026-09-08)  Runs on anybody's machine: finds your Project Gorgon folder
                       on Windows, WSL, macOS, Linux and Steam Proton, and says
                       which one it found. Reads chat as utf-8 (an accented name
                       used to end the run on Windows), prints a summary of what it
                       is about to do, and stops cleanly on Ctrl-C.
  1.1.0  (2026-09-08)  The arena roster comes from the site rather than four
                       built-in lists, so a new fighter needs no new download.
  1.0.0  (2026-09-07)  First public release.

  In the Gorgonzola source tree this file is gorgon_monitor.py, the self-contained,
  shareable twin of the maintainer's own chat_monitor.py.
"""

__version__ = "1.5.0"

import os
import re
import sys
import json
import time
import hashlib
import datetime
import collections
import urllib.request
import urllib.error
import urllib.parse
from pathlib import Path


# ==============================================================================
#  PART 1 | TALKING TO THE WEBSITE
#
#  One function does every report in this program: hand it a web address and a bundle
#  of facts, and it posts them as JSON. It never raises an error upwards: a website
#  that is down, slow or unhappy prints a line, and the monitor carries on playing.
#
#  (These two helpers are copied in from the Gorgonzola tools rather than imported, so
#  that this stays one file you can read, save anywhere and run with no installation.)
# ==============================================================================

# How many reports the site has accepted this run. Counted here because every push goes
# through post_json, and printed on the way out, so stopping the program tells you what
# it actually did rather than leaving you to scroll back.
REPORTS_MADE = 0


def post_json(server_url, endpoint, payload, headers=None):
    """POST `payload` as JSON to server_url+endpoint; return the parsed response
    dict — the server's error body (plus `status`) on an HTTP error, None only when
    the connection itself failed. Logged, never raised."""
    url  = server_url.rstrip('/') + endpoint
    data = json.dumps(payload).encode('utf-8')
    hdrs = {'Content-Type': 'application/json',
            'User-Agent': f'GorgonMonitor/{__version__}'}
    if headers:
        hdrs.update(headers)
    req = urllib.request.Request(url, data=data, method='POST', headers=hdrs)
    try:
        with urllib.request.urlopen(req, timeout=15) as resp:
            body = json.loads(resp.read().decode('utf-8'))
        if isinstance(body, dict) and body.get('ok'):
            global REPORTS_MADE
            REPORTS_MADE += 1
        return body
    except urllib.error.HTTPError as e:
        # Return the error BODY (never None) so a caller can tell a 429 "come back in
        # N seconds" from a 400 "this payload is wrong" — every caller gates success
        # on result.get('ok'), so an error dict still reads as failure.
        body = {}
        try:
            body = json.loads(e.read().decode('utf-8'))
            print(f"  ERROR {e.code}: {body.get('error', e.reason)}", file=sys.stderr)
        except Exception:
            print(f"  HTTP {e.code}: {e.reason}", file=sys.stderr)
        if not isinstance(body, dict):
            body = {'error': str(body)}
        body.setdefault('error', str(e.reason))
        body['status'] = e.code
        if 'retry_after' not in body:
            try:
                body['retry_after'] = int(e.headers.get('Retry-After'))
            except (TypeError, ValueError):
                pass
        return body
    except urllib.error.URLError as e:
        print(f"  Connection failed: {e.reason}", file=sys.stderr)
        print(f"  Is the server running at {server_url}?", file=sys.stderr)
        return None


def book_timestamp(path):
    """Parse the _YYMMDD_HHMMSS stamp out of a book filename -> datetime, or None."""
    m = re.search(r'_(\d{6})_(\d{6})', path.name)
    if not m:
        return None
    try:
        return datetime.datetime.strptime(m.group(1) + m.group(2), '%y%m%d%H%M%S')
    except Exception:
        return None


# ==============================================================================
#  PART 2 | READING THE GAME'S BOOKS
#
#  Two of the game's in-game books are worth sharing: Today's Weather and the
#  Gardening Almanac. Both are written as marked-up text, roughly like a web page, so
#  these two functions pick the facts back out of them.
#
#  The almanac's times are all relative ("ends in 2 hours"), never a clock time, which
#  is why the moment the book was read has to be carried along with it.
# ==============================================================================

def parse_weather_text(text):
    """TodaysWeather book -> [(zone, weather)]. Each line is '<b>Zone</b>: Weather'."""
    zones = []
    for m in re.finditer(r'<b>([^<]+)</b>\s*:\s*([^\n<]+)', text):
        zones.append((m.group(1).strip(), m.group(2).strip()))
    return zones


def _parse_duration_secs(s):
    """'1 day 3 hours 55 minutes' -> total seconds (int)."""
    s = s.lower()
    total = 0
    for val, unit in re.findall(r'(\d+)\s*(day|hour|min|sec)', s):
        v = int(val)
        if unit.startswith('day'):    total += v * 86400
        elif unit.startswith('hour'): total += v * 3600
        elif unit.startswith('min'):  total += v * 60
        elif unit.startswith('sec'):  total += v
    return total


def parse_almanac_text(text, file_time):
    """GardeningAlmanac book -> [event dicts], with file_time as the timestamp
    reference the book's relative times ('ends in 2 hours') resolve against."""
    clean = re.sub(r'</?indent[^>]*>', '', text)   # strip <indent=N> tags
    events = []
    for section_m in re.finditer(r'<h1>(.*?)</h1>(.*?)(?=<h1>|$)', clean, re.DOTALL):
        header = section_m.group(1).strip()
        body   = section_m.group(2).strip()
        is_current = 'current' in header.lower()
        for block in re.split(r'(?=<h2>)', body):
            title_m = re.search(r'<h2>([^<]+)</h2>', block)
            if not title_m:
                continue
            title = title_m.group(1).strip()
            desc_m = re.search(r'</h2>\s*([^<\n]+)', block)
            desc = desc_m.group(1).strip() if desc_m else ''
            time_m = re.search(r'<i>([^<]+)</i>', block)
            if not time_m:
                continue
            dur_m = re.search(r'(?:ends|starts)\s+in\s+(.+)', time_m.group(1).strip(), re.IGNORECASE)
            if not dur_m:
                continue
            dur_secs  = _parse_duration_secs(dur_m.group(1))
            abs_ts_ms = int((file_time.timestamp() + dur_secs) * 1000)
            zone_m = re.search(r'\s+[Ii]n\s+(.+)$', title)
            zone   = zone_m.group(1).strip() if zone_m else ''
            crop_m = re.match(r'(.+?)\s+Season', title)
            crop   = crop_m.group(1) if crop_m else title.split()[0]
            events.append({
                'active':    is_current,
                'title':     title,
                'desc':      desc,
                'crop':      crop,
                'zone':      zone,
                'abs_ts_ms': abs_ts_ms,
                # No 'emoji': the server computes it from garden_crops.json on save
                # (GET /api/garden-options), so the monitor stays plant-agnostic.
            })
    return events


# ==============================================================================
#  PART 3 | SETTINGS
#
#  Where your game keeps its logs, and a handful of timings and safety limits.
#
#  You should not have to change anything here. The game folder is found for you on
#  Windows, WSL, macOS, Linux and Steam Proton, and if the search comes up empty the
#  program says exactly where it looked and stops, rather than running on doing
#  nothing. Point it straight at the folder with --game-dir if your install is
#  somewhere unusual.
#
#  Anything reading os.environ below is an optional extra that stays switched off
#  unless you set it.
# ==============================================================================

DEFAULT_SERVER  = "https://gorgonzola.spellboundtavern.com/"

# Project Gorgon is a Unity game, and Unity puts a game's files in a different place on
# every operating system. These are those places, in the order they are tried. The
# first one that actually holds ChatLogs, Books or Player.log wins.
#
# A `*` is a wildcard: under WSL the Windows drive letter and your Windows username are
# both unknown, and under Steam Proton the game sits inside a numbered prefix, so those
# entries are searched rather than named.
GAME_DIR_CANDIDATES = [
    # Windows (the ordinary case). LOCALAPPDATA is ...\AppData\Local; the sibling
    # LocalLow is where Unity writes, and it has no environment variable of its own.
    ('Windows',      '{USERPROFILE}/AppData/LocalLow/Elder Game/Project Gorgon'),
    # WSL: the same folder, reached through the mounted Windows drive.
    ('WSL',          '/mnt/*/Users/*/AppData/LocalLow/Elder Game/Project Gorgon'),
    # macOS. Note Unity splits this one: the data is here, Player.log is not (below).
    ('macOS',        '{HOME}/Library/Application Support/Elder Game/Project Gorgon'),
    # Linux, playing natively.
    ('Linux',        '{HOME}/.config/unity3d/Elder Game/Project Gorgon'),
    # Linux, playing the Windows build through Steam Proton: a Windows filesystem
    # inside a numbered compatibility prefix.
    ('Steam Proton', '{HOME}/.steam/steam/steamapps/compatdata/*/pfx/drive_c/users/'
                     'steamuser/AppData/LocalLow/Elder Game/Project Gorgon'),
    ('Steam Proton', '{HOME}/.local/share/Steam/steamapps/compatdata/*/pfx/drive_c/'
                     'users/steamuser/AppData/LocalLow/Elder Game/Project Gorgon'),
]

# macOS is the one place Unity does NOT keep Player.log beside the game's data, so a
# found macOS folder gets its log looked for here instead. Everywhere else the two are
# the same folder, which is why find_game_dir returns a PAIR.
MAC_LOG_DIR = '{HOME}/Library/Logs/Elder Game/Project Gorgon'

# What makes a folder the right one. Any single hit is enough: a player who has never
# exported a book has no Books folder, and a fresh install has no ChatLogs yet.
GAME_DIR_MARKERS = ('ChatLogs', 'Books', 'Player.log')


def _expand(pattern):
    """Fill {HOME} / {USERPROFILE} into a candidate path and expand any wildcard.

    Returns the list of real folders it matches, which is empty for every candidate
    belonging to an operating system you are not on.
    """
    home = os.path.expanduser('~')
    path = pattern.format(HOME=home, USERPROFILE=os.environ.get('USERPROFILE', home))
    if '*' not in path:
        p = Path(path)
        return [p] if p.is_dir() else []
    # Path.glob needs a fixed root to search from, so split the pattern at its first
    # wildcard and glob the remainder relative to what comes before it.
    head, _, tail = path.partition('*')
    root = Path(head).parent if not head.endswith('/') else Path(head)
    rest = path[len(str(root)):].lstrip('/')
    try:
        return sorted(p for p in root.glob(rest) if p.is_dir())
    except (OSError, ValueError):
        return []


def find_game_dir(explicit=None):
    """Locate Project Gorgon's folder: (data_dir, log_dir), or None if not found.

    `explicit` is --game-dir or GORGON_GAME_DIR, and it is taken at its word: if you
    name a folder, that is the folder, even if it looks empty, because you may be
    pointing at a copy of somebody else's logs.
    """
    if explicit:
        d = Path(explicit).expanduser()
        return (d, d)
    for label, pattern in GAME_DIR_CANDIDATES:
        for d in _expand(pattern):
            if any((d / m).exists() for m in GAME_DIR_MARKERS):
                log_dir = d
                if label == 'macOS':
                    for alt in _expand(MAC_LOG_DIR):
                        log_dir = alt
                        break
                return (d, log_dir)
    return None


def use_game_dir(data_dir, log_dir=None):
    """Point the four path constants below at a game folder.

    They stay module-level constants because two dozen places read them; this is the
    one function that ever moves them, called once at startup.
    """
    global GAME_DIR, LOG_FOLDER, BOOKS_DIR, PLAYER_LOG, PLAYER_PREV_LOG
    data_dir        = Path(data_dir)
    log_dir         = Path(log_dir) if log_dir else data_dir
    GAME_DIR        = data_dir
    LOG_FOLDER      = data_dir / 'ChatLogs'
    BOOKS_DIR       = data_dir / 'Books'
    PLAYER_LOG      = log_dir / 'Player.log'
    PLAYER_PREV_LOG = log_dir / 'Player-prev.log'


def game_dir_help(explicit=None):
    """The message shown when no game folder could be found: where it looked, and how
    to name the right one. A silent do-nothing run is the worst outcome available here,
    so this is deliberately loud.

    `explicit` is the folder the reader named themselves, if they named one. Listing the
    default search paths at somebody who overrode them would answer a question they did
    not ask, so their folder is named first and the defaults are labelled as defaults.
    """
    if explicit:
        lines = [f'No ChatLogs, Books or Player.log in: {explicit}', '',
                 'That is the folder you named. It should be the one holding your',
                 'ChatLogs and Books folders. Without --game-dir, these are looked at:']
    else:
        lines = ["Could not find your Project Gorgon folder.", "", "Looked in:"]
    for label, pattern in GAME_DIR_CANDIDATES:
        lines.append(f"  {label:<13} {pattern.format(HOME='~', USERPROFILE='%USERPROFILE%')}")
    lines += [
        "",
        "If the game is installed somewhere else, point at the folder that holds your",
        "ChatLogs and Books folders:",
        "",
        "    python3 gorgon_monitor.py --game-dir \"path/to/Project Gorgon\"",
        "",
        "(or set the GORGON_GAME_DIR environment variable to the same path).",
    ]
    return '\n'.join(lines)


# Found at import so the constants exist for the two dozen places that read them; the
# command line can move them afterwards with use_game_dir(). A blank default is fine:
# nothing reads a path until the monitor starts, and __main__ refuses to start without
# a real folder.
GAME_DIR        = Path('.')
LOG_FOLDER      = Path('.')
BOOKS_DIR       = Path('.')
PLAYER_LOG      = Path('.')
PLAYER_PREV_LOG = Path('.')
_found = find_game_dir(os.environ.get('GORGON_GAME_DIR', '').strip() or None)
if _found:
    use_game_dir(*_found)

# Mob-drop push (POST /api/loot → the community bestiary observed-drop DB). Buffered
# and flushed in batches, never per log line. The endpoint is OPEN for now; LOOT_TOKEN
# is a placeholder sent as a Bearer header if set, so this same code can be community-
# gated later (poisoning defense) without a client change. See _scan_loot / _flush_loot.
LOOT_TOKEN         = os.environ.get('GORGON_LOOT_TOKEN', '').strip()
LOOT_FLUSH_SECS    = 15     # max seconds a buffered loot event waits before a flush
LOOT_MAX_BUFFER    = 40     # or flush early once this many events are queued
LOOT_BUFFER_CAP    = 1000   # hard cap so a persistent outage can't grow it unbounded

# Arena-bet push (POST /api/bets → community win/loss ratios). Resolved bets are rare
# (a few a day), so each is pushed immediately on resolution. Open + per-ip cooldown;
# BETS_TOKEN is a placeholder Bearer header for later community gating, like LOOT_TOKEN.
BETS_TOKEN         = os.environ.get('GORGON_BETS_TOKEN', '').strip()

# Casino hot-tip mirror to redwing.bet (POST /api/v1/tips, {fighter,matchup,adjustment}).
# OFF unless GORGON_KEY is set; sent as a Bearer token. Every casino tip we report to
# gorgonzola is also forwarded here. Isolated: a redwing failure never touches the
# gorgonzola report or the monitor loop. See _mirror_redwing_tip / _redwing_payload.
REDWING_URL   = os.environ.get('REDWING_URL', 'https://redwing.bet').strip().rstrip('/')
REDWING_TOKEN = os.environ.get('GORGON_KEY', '').strip()
# redwing sits behind Cloudflare, which 403s the default 'Python-urllib/x.y' User-Agent.
# Send a real UA (the same one the server's GET uses, which Cloudflare already lets through).
REDWING_UA    = 'GorgonChatMonitor/1.0'

# Live current-fight report to redwing.bet (POST /api/live/report). Separate endpoint,
# shape (numeric fighter IDs + an epoch-ms start time) AND secret from the tip mirror —
# /api/live/report 401s the tips token, so this needs its own LIVE_REPORT_SECRET and is
# OFF until it's set. REDWING_REPORTER identifies this source (falls back to the game
# server). See _mirror_redwing_live_fight.
LIVE_REPORT_SECRET  = os.environ.get('LIVE_REPORT_SECRET', '').strip()
REDWING_REPORTER    = os.environ.get('REDWING_REPORTER', '').strip()
REDWING_FIGHTER_IDS = {'Corrrak': 1, 'Dura': 2, 'Gloz': 3, 'Leo': 4,
                       'Otis': 5, 'Ushug': 6, 'Viz': 7}   # Viz = redwing's Vizlark

# Books/ exports carry RELATIVE times ("ends in 2 hours") that the server resolves
# against the moment it receives them, so a stale export doesn't just add nothing —
# it OVERWRITES fresher world state with already-expired seasons/alerts. An export
# older than this is baselined and never uploaded (a book read in-game arrives via
# Player.log within seconds, so this only ever rejects genuinely old files).
BOOK_MAX_AGE_SECS  = 2 * 3600
# How long to wait before re-trying a book push the server rejected, when it didn't
# tell us (no `retry_after`). Without this the 10-second book poll retried a
# rate-limited garden book ~360 times an hour.
BOOK_RETRY_SECS    = 300

# Worlds the site keeps per-server data for. The live server is the authority
# (GET /api/reporting-schema, read once at startup); this is the offline fallback.
VALID_GAME_SERVERS = {'strekios', 'miraverre', 'laeth', 'dreva', 'arisetsu'}
# Which world to assume when Player.log doesn't say (an old log, a fresh install).
# Pass --game-server to override. Auto-detection is right in practically every case,
# so this only decides where a first report goes before you have logged in once.
FALLBACK_GAME_SERVER = 'dreva'

# ==============================================================================
#  PART 4 | PATTERNS: WHAT A LOG LINE LOOKS LIKE
#
#  This is the part that does the actual reading, and the part worth checking if you
#  are suspicious of what this program looks at.
#
#  Project Gorgon's client writes a running log to Player.log: every book you open,
#  every NPC screen you read, every item you loot, every death. It is plain text and
#  you can open it yourself. Each pattern below describes ONE kind of line, with a real
#  example quoted above it, and nothing else in the log is looked at at all.
#
#  If you want to see for yourself, open Player.log in a text editor and search for one
#  of the quoted examples. That is exactly, and only, what this program sees.
# ==============================================================================

# Player.log login marker (verified against a live log): "Logged in as character X."
# names the active character, used to attribute mob-drop observations to a toon.
LOGIN_RE = re.compile(r'Logged in as character (\w+)\.')

# Lady Alethina's daily Aurest mission comes from HER CONVERSATION, not the quest:
# when she gives it, the talk screen body is a localization key naming the quest, e.g.
#   ProcessTalkScreen(11370, "", "<<<quest_25523_Preface>>>", ...)
# The quest id resolves to the mission name via quests.json (works for every quest /
# any future mission); the map below is a fallback if quests.json isn't present. An
# Aurest mission is any quest named "Aurest Mission: X"; X → /nb/aurest/set.
AUREST_PREFACE_RE = re.compile(r'<<<quest_(\d+)_Preface>>>')
AUREST_PREFIX     = 'Aurest Mission: '
AUREST_MISSIONS   = {          # fallback (quest id -> mission), used only if quests.json is absent
    25521: 'Labyrinth Chests',      25522: "Claudia's Hoard",
    25523: 'Gazluk Assassination',  25524: "Gazluk's Dark Basement",
    25525: 'Rahu Sewers',           25526: 'Wintertide Assault',
    25527: 'Wintertide Sabotage',
}

# Agrashab's daily group bounty works exactly like the Aurest mission: the Fae Realm
# god's talk screen names the day's quest via a preface localization key, e.g.
#   ProcessTalkScreen(15054, "", "<<<quest_25104_Preface>>>", ...)
# The quest resolves to "Agrashab's Bounty: X"; X (the target) → /nb/agrashab/set.
# Same generic <<<quest_N_Preface>>> matcher (AUREST_PREFACE_RE) drives both.
AGRASHAB_PREFIX   = "Agrashab's Bounty: "
AGRASHAB_TARGETS  = {          # fallback (quest id -> target), used only if quests.json is absent
    25104: 'Phoenices',            25105: 'Fire Droaches',
    25106: 'Shocking Droaches',    25107: 'Basilisk Controllers',
    25108: 'Winter War-Trolls',
}

# Vampire "Current Blood Season" is announced in a talk screen, e.g.
#   ProcessTalkScreen(5783, "", "<h1>Current Blood Season:</h1>...<h2>Giant Blood</h2>...")
# The <h2>X Blood</h2> names the day's blood target (global → /api/blood-target). The
# capture handles multi-word targets ("Big Cat", "Fish and Snail").
BLOOD_SEASON_RE = re.compile(r'Current Blood Season:.*?<h2>\s*([^<]+?)\s+Blood\s*</h2>',
                             re.IGNORECASE | re.DOTALL)

TS_RE              = re.compile(r'^\d{2}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\t')
BLOOD_TARGET_RE    = re.compile(r'blood_target_update:\s*(.*)', re.IGNORECASE)
BULK_TIP_START_RE  = re.compile(r'^"\w[\w\s]*[Tt]ips\w*\s*\(\d+/\d+\):', re.IGNORECASE)

# Player.log death award, e.g.
#   ProcessDeathMessage("<<<cause_of_death_EyeOfDeath>>>", 50, True, 5, False)
# Groups: cause key, death XP, firstTime (first death from this cause). The XP is
# the canonical "death XP" ONLY when firstTime=True (50); repeat deaths award a
# small diminishing amount (0/1/5) — the useful "repeat XP" is the HIGHEST of those,
# since it decays toward 0 over successive deaths. Two trailing values follow that we
# don't use. Recorded locally AND pushed to the site's death-XP overlay
# (POST /api/death-xp) so it can fill deaths.json's null xp/rxp on the Dying page —
# see _record/_init_deaths_state + _report_deaths_xp.
DEATH_RE = re.compile(
    r'ProcessDeathMessage\("<<<cause_of_death_([A-Za-z0-9_]+)>>>",\s*(\d+),\s*(True|False),'
)

# Player.log world-report books (weather / gardening almanac / warden alerts).
# OPENING one of these in-game always emits a ProcessBook line; EXPORTING it to
# Books/*.txt is a separate manual step the player usually skips. The two carry
# byte-identical bodies (same <h1>/<b>/<indent=20> markup), the only difference
# being that the log escapes newlines as literal backslash-n. So the log is the
# strictly better source — same content, no export required — and _scan_books
# feeds the very same parsers the Books/ watcher uses.
# The call is ProcessBook(title, body, exportName, "", "", False×5, ""). Title and
# body are matched as proper quoted strings — `(?:[^"\\]|\\.)*` stops at the first
# UNESCAPED quote — rather than by anchoring on the arguments that follow. An
# earlier version anchored on a literal `", "", ""` and silently over-captured:
# weather and warden leave exportName empty so it appeared to work, but the
# almanac sets it ("GardeningAlmanac", which is where its export filename comes
# from), so the body ran on past its closing quote. Never anchor on a trailing
# argument that only SOME books populate.
# No DOTALL: one ProcessBook is always ONE physical line and `.` must not run past it.
# Group 3 is the book's EXPORT NAME, the filename base the game itself uses when you
# click "save this book" ("Today's Shop Logs" exports as PlayerShopLog_*.txt, and
# shop_pnl.html only recognises that name). Most books leave it empty and are named
# from the title instead, so the group is optional. See _book_filename.
BOOK_LOG_RE = re.compile(r'ProcessBook\("((?:[^"\\]|\\.)*)",\s*"((?:[^"\\]|\\.)*)"'
                         r'(?:,\s*"((?:[^"\\]|\\.)*)")?')
# In-log book titles -> the kind we report. All three VERIFIED against a live log.
# The almanac's in-game title ("Gardening Almanac") differs from its export
# filename (GardeningAlmanac_*.txt), and it has NEVER been exported here — the
# log is the only source that has ever carried a gardening almanac at all.
BOOK_KINDS = [
    ('weather', re.compile(r"^Today's Weather$", re.I)),
    ('warden',  re.compile(r'^Active Warden Alerts$', re.I)),
    ('garden',  re.compile(r'^Gardening Almanac$', re.I)),
]
_BOOK_SIG_HISTORY = 500  # warden sigs remembered, so a stale re-export can't regress
                         # a fresher push; 500 distinct snapshots far outlasts any
                         # realistic delayed-export window, at ~40 bytes each.

# ── Book auto-save ────────────────────────────────────────────────────────────
# Books the monitor WRITES to disk for you, so the in-game "save this book" click can
# be skipped. Same reasoning as BOOK_KINDS above: opening a book emits a ProcessBook
# line carrying the very body the save would write, so we can produce the file the
# player would have produced — same name shape, same body-only content, same CRLF
# (see _write_book_file). Order books are the default because shop_pnl.html books a
# filled work order as a material cost from exactly this file.
# EVERY book you open is saved by default. Opening one is the only signal there is
# that you wanted it, and a book you have to remember to save is a book you lose: the
# weather, garden and warden reports all come from a saved book file, so a curated list
# meant a player could read the almanac in game and still report nothing.
# Configure with GORGON_SAVE_BOOKS / --save-books: 'off' disables, 'all' is the
# default, or pass a comma-separated list of title substrings to narrow it (a piece of
# a title is enough, so 'Shop Logs' catches both today's and yesterday's, which the
# game exports as PlayerShopLog_*.txt, the file shop_pnl.html reads for revenue).
# Saved books stay on YOUR disk: one is never uploaded anywhere, and a shop log names
# your customers, so it is one of the things this program deliberately does not report.
SAVE_BOOKS_DEFAULT = 'all'
# Written filenames match the game's own convention exactly: the book's export name
# when it has one, else its title with every non-alphanumeric character dropped, then
# _YYMMDD_HHMMSS (LOCAL time) and .txt. "Order Fulfilled" -> OrderFulfilled_260902_200023.txt,
# "Today's Shop Logs" -> PlayerShopLog_260907_210009.txt, matching TodaysWeather_*.txt
# and ActiveWardenAlerts_*.txt on disk. Consumers date a book from this name, since a
# saved book's own text carries no timestamp.
_BOOKS_WRITTEN_MAX = 2000   # remembered book signatures, oldest dropped

# Player.log mob-loot markers (verified against a live log). Looting a killed creature
# emits, in order across several lines:
#   ProcessTalkScreen(<eid>, "Search Corpse of <Mob>", "<body>", "", [], ..., 0, Corpse)
#   ProcessAddItem(<InternalName>(<instanceId>), -1, True)   ← item enters inventory
#   ProcessRemoveLoot(<instanceId>)                          ← SAME id confirms loot origin
# CORPSE_RE names the mob (the trailing ", 0, Corpse)" distinguishes a corpse loot
# window from an NPC/dialog talk screen). LOOT_ADD_RE's trailing True is the real-
# acquisition flag (False = inventory sync on login — ignored); an add is only counted
# as a drop once a matching ProcessRemoveLoot with the SAME instanceId is seen, which
# filters out crafting/quest/vendor adds that never produce a RemoveLoot. Item is an
# InternalName (stable id), not the display name. See _scan_loot.
# The trailing int differs by screen: the opening/refresh corpse screen ends ", 0, Corpse)";
# a harvest RESULT summary ends ", 1, Corpse)". Match either (was hardcoded 0, which missed
# every harvest summary) and let _harvest_action read the verb to tell them apart.
CORPSE_RE     = re.compile(r'ProcessTalkScreen\((\d+),\s*"Search Corpse of (.+?)",.*,\s*-?\d+,\s*Corpse\)')
LOOT_ADD_RE   = re.compile(r'ProcessAddItem\((\w+)\((\d+)\),\s*-1,\s*True\)')
LOOT_REMOVE_RE = re.compile(r'ProcessRemoveLoot\((\d+)\)')

# A corpse's harvest RESULT summary names the action, and it is the RELIABLE signal for
# skin/butcher/extract: those produce items straight to inventory (AddItem + UpdateItemCode)
# and never emit ProcessRemoveLoot, so the base-loot AddItem+RemoveLoot pairing below misses
# them. The opening/refresh corpse screen carries no verb. See _scan_loot / _harvest_action.
HARVEST_VERBS = (('butchered the corpse', 'butcher'),
                 ('skinned the corpse', 'skin'))
# The player's current zone is named on each area load, e.g. "Loading Level AreaSerbule".
# Player.log has no finer position and no mob levels, so zone is as specific as sourcing gets.
ZONE_RE = re.compile(r'Loading Level (Area\w+)')

# ── Kuzavek arena fighter tip parsing ────────────────────────────────────────

_FIGHTERS = {
    "otis": "Otis", "otiz": "Otis",
    "gloz": "Gloz", "goz": "Gloz",
    "dura": "Dura",
    "ushug": "Ushug", "ush": "Ushug",
    "viz": "Viz", "vizlark": "Viz", "vozlark": "Viz",
    "leo": "Leo",
    "corrrak": "Corrrak", "corrak": "Corrrak", "crrrak": "Corrrak",
    "corr": "Corrrak", "cor": "Corrrak",
    "na'a": "Na'a", "naa": "Na'a", "naaa": "Na'a",
    "lamp": "Lamp",
}

# Canonical names for arena outcome tracking (separate from tip-parsing _FIGHTERS)
_ARENA_FIGHTERS = {
    'cor':     'Corrrak',   # matches corr/corrr/cooo variants after dedup
    'dura':    'Dura',
    'gloz':    'Gloz',
    'leo':     'Leo',
    'otis':    'Otis',
    'ushug':   'Ushug',
    'viz':     'Vizlark',
    'vizlark': 'Vizlark',
}

FIGHT_TIMEOUT = 240   # seconds — discard fight state older than this

_DEDUP_RE = re.compile(r'(.)\1+')

def _dedup(s):
    """Collapse runs of repeated characters: 'corrrrrrr' → 'cor'."""
    return _DEDUP_RE.sub(r'\1', s)

def _canon_fighter(text):
    """Return canonical arena fighter name from raw text (handles all-caps stretches)."""
    for word in reversed(re.findall(r'[A-Za-z]+', text)):
        s = _dedup(word.lower())
        for key, name in _ARENA_FIGHTERS.items():
            kd = _dedup(key)
            if s.startswith(kd) or (kd.startswith(s) and len(s) >= 3):
                return name
    return None

def _parse_line_ts(line):
    """Parse the log-line timestamp prefix into a datetime, or None."""
    m = re.match(r'^(\d{2}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})', line)
    return datetime.datetime.strptime(m.group(1), "%y-%m-%d %H:%M:%S") if m else None

# The fighter maps above are the OFFLINE FALLBACK. The live site is the authority:
# _refresh_fighters() below replaces them (and rebuilds the two patterns that are built
# from them) with GET /api/reporting-schema's `fighters` at startup, so a roster change
# on the site does not need a new copy of this file. They had drifted before this existed
# - _FIGHTERS called Viz "Viz" while _ARENA_FIGHTERS called the same fighter "Vizlark".
_F = "|".join(re.escape(k) for k in _FIGHTERS)

_TIP_DIR_RE = re.compile(
    rf"\b({_F})\s+(?:over|>)\s+({_F})(?:\s+([+-]?\d+)%?)?(?=\s|$|[^\w])", re.IGNORECASE
)
_TIP_SOLO_RE = re.compile(rf"\b({_F})\s+([+-]\d+)%?(?=\s|$|[^\w])", re.IGNORECASE)


def _apply_fighter_schema(data):
    """Replace the fighter maps with the site's, and rebuild what is derived from them.

    `data` is /api/reporting-schema's `fighters` block. Anything missing or malformed
    leaves the built-in fallback untouched, because a half-applied roster is worse than
    a slightly old one. The two tip patterns are alternations built FROM the alias list,
    so they must be recompiled here or they would still only match yesterday's names.
    """
    global _FIGHTERS, _ARENA_FIGHTERS, REDWING_FIGHTER_IDS, REDWING_FIGHTER_NAMES
    global _F, _TIP_DIR_RE, _TIP_SOLO_RE
    if not isinstance(data, dict):
        return False
    aliases = data.get('aliases')
    if not isinstance(aliases, dict) or not aliases:
        return False
    _FIGHTERS = {str(k).lower(): str(v) for k, v in aliases.items()}
    # Outcome tracking matches on prefixes of these keys, so it takes the same map.
    _ARENA_FIGHTERS = dict(_FIGHTERS)
    ids = data.get('redwing_ids')
    if isinstance(ids, dict):
        REDWING_FIGHTER_IDS = {str(k): int(v) for k, v in ids.items()}
    names = data.get('redwing_names')
    if isinstance(names, dict):
        REDWING_FIGHTER_NAMES = {str(k): str(v) for k, v in names.items()}
    _F = "|".join(re.escape(k) for k in _FIGHTERS)
    _TIP_DIR_RE = re.compile(
        rf"\b({_F})\s+(?:over|>)\s+({_F})(?:\s+([+-]?\d+)%?)?(?=\s|$|[^\w])", re.IGNORECASE)
    _TIP_SOLO_RE = re.compile(rf"\b({_F})\s+([+-]\d+)%?(?=\s|$|[^\w])", re.IGNORECASE)
    return True


def _parse_fight_tip(text):
    """Turn one line of casino tip chatter into a tidy one-line summary, or None.

    Two shapes are understood: a matchup ("Otis over Gloz +10%") and a fighter on
    their own ("Gloz +5%"). Printed to your console only. The tips actually reported
    to the site are the ones read from the NPC in _scan_hot_tips.
    """
    m = _TIP_DIR_RE.search(text)
    if m:
        fa = _FIGHTERS[m.group(1).lower()]
        fb = _FIGHTERS[m.group(2).lower()]
        if m.group(3) is not None:
            pct = int(m.group(3))
            sign = "+" if pct >= 0 else ""
            return f"{fa} over {fb}  {sign}{pct}%"
        return f"{fa} over {fb}"
    m = _TIP_SOLO_RE.search(text)
    if m:
        fa = _FIGHTERS[m.group(1).lower()]
        pct = int(m.group(2))
        sign = "+" if pct >= 0 else ""
        return f"{fa}  {sign}{pct}%"
    return None


# redwing.bet expects full fighter names; our system (and gorgonzola) uses the short
# canonical forms. Only Viz differs — remap our name → redwing's on the way out.
REDWING_FIGHTER_NAMES = {'Viz': 'Vizlark'}


def _redwing_payload(key):
    """Translate a gorgonzola gambling-tip key into the redwing.bet /api/v1/tips body,
    or None for a key shape redwing can't take. Edge tips carry the matchup
    ('edge:winner:loser' → fighter=winner, matchup=loser, adjustment +10); the fighter
    modifiers ('plus5:X'/'minus5:X' → adjustment ±5) have no opponent, so `matchup` is
    omitted. Fighter names are remapped to redwing's full-name vocabulary. Kept
    module-level and side-effect-free so it's unit-testable."""
    def rw(name):
        return REDWING_FIGHTER_NAMES.get(name, name)
    parts = key.split(':')
    if parts[0] == 'edge' and len(parts) == 3 and parts[1] and parts[2]:
        return {'fighter': rw(parts[1]), 'matchup': rw(parts[2]), 'adjustment': 10}
    if parts[0] in ('plus5', 'minus5') and len(parts) == 2 and parts[1]:
        return {'fighter': rw(parts[1]), 'adjustment': 5 if parts[0] == 'plus5' else -5}
    return None


# Casino "Here's a hot tip:" / "Gotta hot tip for ya:" NPC reads in Player.log (UTC).
# Two shapes seen in a live log:
#   edge : "If Dura and Corrrak fight today, ... that Dura will have the upper hand. (+10%)"
#   solo : "Gloz ... She's feeling great! (+5%)"  /  "... in great form today. (-5%)"
_HOT_TIP_LINE_RE = re.compile(r"\bhot tip\b", re.IGNORECASE)
_HOT_TIP_EDGE_RE = re.compile(
    rf"\bIf\s+({_F})\s+and\s+({_F})\s+fight today\b.*?\bthat\s+({_F})\s+will have the upper hand\b"
    rf".*?\(([+-]?\d+)%\)", re.IGNORECASE)
_HOT_TIP_SOLO_RE = re.compile(rf"\b({_F})\b.*?\(([+-]?\d+)%\)", re.IGNORECASE)
_PLAYERLOG_TS_RE = re.compile(r"^\[(\d{2}:\d{2}:\d{2})\]")

# Kyuroma the bookie's live-matchup talk screen, e.g.
#   ProcessTalkScreen(13767, "", "The next fight will be between Otis and Dura. Would
#   you like to wager...?\n\nThe fight begins in <em>4 minutes 55 seconds</em>.", ...)
# The two fighters + a relative countdown; reported to /api/current-fight (the server
# resolves the countdown to a start time at receipt, like weather/garden books).
_NEXT_FIGHT_RE = re.compile(
    rf"next fight will be between\s+({_F})\s+and\s+({_F})\b", re.IGNORECASE)

def _parse_fight_countdown(text):
    """Seconds until the fight begins, from 'begins in <em>4 minutes 55 seconds</em>'
    (either unit may be absent). Scans minutes and seconds independently in the segment
    after 'begins in'; 0 when no countdown is present (fight imminent)."""
    m = re.search(r"begins in\b(.*)", text, re.IGNORECASE)
    seg = m.group(1) if m else text
    mins = re.search(r"(\d+)\s*minute", seg, re.IGNORECASE)
    secs = re.search(r"(\d+)\s*second", seg, re.IGNORECASE)
    return (int(mins.group(1)) * 60 if mins else 0) + (int(secs.group(1)) if secs else 0)


# Kyuroma arena BETTING (Player.log). A placed-and-resolved bet spans three talk screens:
#   Confirm Bet: "You are betting <em>N</em> Councils that A defeats B in the next arena
#                 match. ..."  — names the matchup (who bet on vs opponent).
#   Placement:   "Success! You have placed your bet for A. ..."  — only on a REAL placement,
#                 never on a cancelled preview, so this is what proves the bet went through.
#   Outcome:     "Your previous bet was correct, and your fighter won!"  OR
#                "Your previous bet was incorrect. Your fighter lost. Sorry."
# The outcome for a battle arrives at the NEXT Kyuroma visit, and it's one bet per battle,
# so the state machine (pending -> open -> resolved) never overlaps. Only the matchup +
# win/loss matters (wager amounts are not tracked). Fighter names are captured freely
# (multi-word / apostrophes like Na'a), not gated on the known set. See _scan_bets.
BET_CONFIRM_RE = re.compile(
    r'betting <em>\d+</em> Councils that (.+?) defeats (.+?) in the next arena match', re.DOTALL)
BET_PLACED_RE  = re.compile(r'You have placed your bet for (.+?)\.\s')
BET_WIN_RE     = re.compile(r'previous bet was correct')
BET_LOSS_RE    = re.compile(r'previous bet was incorrect')


# Intro: capture everything after the position phrase; _canon_fighter does the name resolution
INTRO_RE = re.compile(
    r"(?:far side is|farthest from me is|near side is|side closer to me is|nearest me is)\s+(.*)",
    re.IGNORECASE
)


def _extract_winner(line):
    """The winner named in a line of Kuzavek's arena commentary, or None.

    Three phrasings, all seen in real chat: "X wins", "the battle goes to X" and
    "victory for X". The name is then canonicalised, because the announcer stretches
    it out when excited (CORRRRRAK).
    """
    m = re.search(r"([A-Z][A-Za-z]+)\s+(?:wins?|has won|cleans up)\b", line, re.IGNORECASE)
    if m:
        return _canon_fighter(m.group(1))
    m = re.search(r"battle goes to ([A-Z][A-Za-z]+)", line, re.IGNORECASE)
    if m:
        return _canon_fighter(m.group(1))
    m = re.search(r"victory for ([A-Z]+)", line, re.IGNORECASE)
    if m:
        return _canon_fighter(m.group(1))
    return None


def _extract_loser(line):
    """The loser named in a line of arena commentary, or None. Sometimes the
    commentary only names who went down ("X has fallen", "X is out"), so the winner
    is whoever else was in the ring."""
    m = re.search(
        r"([A-Z][A-Za-z]+)\s+(?:loses?|has lost|has fallen|has collapsed)\b",
        line, re.IGNORECASE
    )
    if m:
        return _canon_fighter(m.group(1))
    m = re.search(r"([A-Z][A-Za-z]+)\s+is out\b", line, re.IGNORECASE)
    if m:
        return _canon_fighter(m.group(1))
    return None


# ==============================================================================
#  PART 5 | THE MONITOR
#
#  Everything from here on lives on one object. It is long, but it is repetitive: a
#  short block per thing the program watches for, in the order they appear in the file.
#  The class docstring immediately below explains the shape they all share.
# ==============================================================================

class ChatMonitor:
    """The monitor itself: one object that watches your logs and reports what it finds.

    It keeps three files under watch, all of them written by the game:

        ChatLogs/Chat-YY-MM-DD.log   what was said around you (arena commentary)
        Player.log                   the client's own log: books you opened, NPCs
                                     you talked to, what you looted, how you died
        Books/*.txt                  books you exported by hand in game

    Everything else here is one method per thing worth noticing. They all follow the
    same three-step shape, and once you have read one you have read them all:

        _init_<thing>   run once at startup: look back through the log so today's
                        answer is picked up even if you saw it before starting this
        _scan_<thing>   run on each new slice of log: notice the thing
        _report_<thing> send it to the site, if it is new and this world is tracked

    Two habits run through the whole class and are worth knowing about:

    FORWARD-ONLY.  On startup it notes how big each log already is and reads only what
    is written after that, so restarting it never re-reports things you did yesterday.

    NOTHING IS ALLOWED TO CRASH IT.  Every scanner is wrapped in its own try/except.
    A game patch that changes the wording of one line should cost you that one feature
    for the session, printing a complaint, while everything else carries on.
    """

    def __init__(self, server_url=DEFAULT_SERVER, game_server=None, data_dir=None,
                 save_books=None):
        """Set everything up, then look back over the logs so today's answers are
        already known before the first new line arrives.

        Nothing here is required of you: which world you are on is read out of
        Player.log, and every _init_ call below is wrapped so that a missing or
        unreadable file costs that one feature and never the whole program.
        """
        self.server_url       = server_url
        self._schema          = None     # /api/reporting-schema, fetched once (see _reporting_schema)
        self._refresh_fighters()         # before _scan_existing reads a single fight line
        self.game_server      = game_server
        self._detected_world  = False
        if not self.game_server:
            detected = self._detect_server()
            self.game_server = detected or FALLBACK_GAME_SERVER
            self._detected_world = bool(detected)
            if not detected:
                print(f"[server] World not detected, assuming {FALLBACK_GAME_SERVER}. "
                      f"Use --game-server if that is wrong.")
        self.world_reports    = self._world_is_tracked()
        self._current_fighters = []
        self._fight_start       = None
        self._fight_last        = None   # (a,b) of the last current-fight we reported
        self._fight_last_at     = 0.0    # monotonic time of that report (re-report gate)
        self._last_weather_path = None
        self._last_garden_path  = None
        self._last_warden_path  = None
        self._last_book_check   = 0.0
        # kind -> sha1 of the LAST body reported, so a book seen BOTH in Player.log
        # and as a Books/ export is pushed once. Weather/garden legitimately repeat
        # (a weather pattern recurs on a later day), so last-sig is right for them —
        # a set would wrongly swallow the real repeat. See _report_book.
        self._book_sigs         = {}
        # kind -> epoch before which a FAILED push is not re-attempted. A rejection
        # (rate limit, server hiccup) must not turn the 10s book poll into a retry
        # storm; the wait is the server's `retry_after` when it sends one, else
        # BOOK_RETRY_SECS. See _report_book.
        self._book_retry_at     = {}
        self._retry_hint        = 0     # seconds the last rejected push asked us to wait
        # Warden alone needs MORE than last-sig: its countdown strictly decreases,
        # so every genuine read differs and no legit snapshot ever repeats — but a
        # delayed Books/ export of an OLDER snapshot arriving after a fresher push
        # would (with last-sig only) sail through and overwrite the server's fresher
        # state. Remembering every warden sig already sent blocks that stale re-send
        # while still forwarding any moved-countdown snapshot (a new sig). Bounded.
        self._warden_sigs       = collections.deque(maxlen=_BOOK_SIG_HISTORY)
        self._tip_block         = None   # accumulated multi-line HotTips block text
        self._last_player_log_check = 0.0
        self._player_log_pos    = 0
        self._last_aurest       = None   # last Aurest mission reported (dedup)
        self._last_agrashab     = None   # last Agrashab bounty reported (dedup)
        self._last_blood        = None   # last blood target reported (dedup)
        self._tips_seen         = set()  # casino tip keys reported this casino day (dedup)
        self._tips_day          = None   # eastern casino-day the tip dedup set belongs to
        self._active_char       = None   # current character (LOGIN_RE), tags mob drops
        self._quests_cat        = None   # lazy quests.json (quest_<id> -> {...})
        data_root = Path(data_dir) if data_dir else Path(__file__).parent
        # Local death-XP research log (kept locally AND pushed to the site overlay).
        # Maps each cause_of_death key -> observed death-XP. See _init_deaths_state.
        self._deaths_file = data_root / 'deaths_observed.json'
        self._deaths_map  = {}
        self._deaths_cat  = None   # lazy deaths.json catalogue (key -> {name, xp})
        # Books written to disk on the player's behalf (see _save_books): sha1 of
        # title+body -> {title, file, seen}. Persisted because re-OPENING a book emits
        # another identical ProcessBook line, and a second file for one work order
        # would book its councils twice in shop_pnl.html.
        self._books_written_file = data_root / 'books_written.json'
        self._books_written = {}
        # Mob-drop capture state (pushed to /api/loot; see _scan_loot). Loot spans
        # several log lines and chunks, so this state lives across polls; it is reset
        # on log rotation (new session = new entity ids). Forward-only: the Player.log
        # cursor baselines at EOF, so restarts never reprocess old loot.
        self._loot_corpse       = None   # (eid, mob) currently open loot window
        self._loot_pending      = {}     # instanceId -> InternalName awaiting RemoveLoot
        self._loot_window_adds  = {}     # instanceId -> InternalName: AddItems since the last
                                         # summary/open, candidates for the next harvest action
        self._loot_seen_corpses = set()  # corpse eids already counted this session
        self._loot_seen_harvest = set()  # (eid, action) already emitted (result screen repeats)
        self._loot_zone         = None   # current area (from ZONE_RE), tags each drop
        self._loot_buffer       = []     # loot/corpse events awaiting a batch flush
        self._last_loot_flush   = 0.0
        # Arena-bet state (see _scan_bets): a Confirm Bet screen fills _bet_pending, the
        # "Success! placed your bet" line promotes it to _bet_open, and the next outcome
        # screen resolves + reports it. Reset on log rotation.
        self._bet_pending       = None   # {fighter, opponent} previewed on a Confirm Bet
        self._bet_open          = None   # the placed bet awaiting its win/loss outcome
        self._init_book_saving(save_books)
        self._print_summary()
        self._scan_existing(print_fights=True)
        self._init_book_state()
        self._init_player_log_state()
        self._init_aurest_state()
        self._init_agrashab_state()
        self._init_blood_state()
        self._init_hot_tips_state()
        self._init_deaths_state()

    # ── Startup ──────────────────────────────────────────────────────────────

    def _print_summary(self):
        """Say what this is, where it is looking and what it will report, before it
        starts. Somebody who has just downloaded a program off the internet should be
        able to answer "what did I just run" from its first few lines, without having to
        take a website's word for it."""
        titles = getattr(self, '_save_titles', None)
        if not titles:
            saving = 'nothing (--save-books is off)'
        else:
            what = 'every book you open' if titles == ['*'] else ', '.join(titles)
            saving = (f"{what}\n                to your own Books folder, never uploaded"
                      f" ({len(self._books_written)} saved so far)")
        how = 'detected from Player.log' if self._detected_world else 'you told me'
        print(f"""
Gorgon Monitor {__version__}
  reporting to  {self.server_url.rstrip('/')}
  world         {self.game_server} ({how})
  game folder   {GAME_DIR}
  saving books  {saving}
  reporting     weather, garden, warden alerts, Aurest, Agrashab, blood season,
                casino tips and fights, arena results, mob drops, death XP
  not sending   chat, inventory, councils, account, or any file on this PC

  Every report is printed below as it is made. Ctrl-C to stop.
""")

    def _scan_existing(self, print_fights=False):
        """Read today's chat log from the top once at startup, to print the arena
        fights that already happened today. Display only: nothing here is reported,
        it just means the window is not blank when you start it mid-session."""
        log_file = LOG_FOLDER / (
            "Chat-" + datetime.datetime.now().strftime("%y-%m-%d") + ".log"
        )
        if not log_file.exists():
            return 0

        fighters_buf  = []
        fight_start_b = None
        # utf-8 with errors='replace', like every other read in this file: chat is full
        # of accented names and emoji, and on Windows the default is the local code page,
        # where one such character would end the run.
        with open(log_file, "r", encoding="utf-8", errors="replace") as f:
            lines = f.readlines()

        for line in lines:
            line = line.strip()
            if "[NPC Chatter] Kuzavek:" not in line:
                continue

            line_dt = _parse_line_ts(line)

            # Timeout: stale fight state
            if fight_start_b and line_dt and (line_dt - fight_start_b).total_seconds() > FIGHT_TIMEOUT:
                fighters_buf  = []
                fight_start_b = None

            fm = INTRO_RE.search(line)
            if fm:
                name = _canon_fighter(fm.group(1))
                if name and name not in fighters_buf:
                    if not fighters_buf:
                        fight_start_b = line_dt
                    fighters_buf.append(name)
                continue

            winner = _extract_winner(line)
            if winner:
                loser = next((f for f in fighters_buf if f != winner), "?")
                if print_fights:
                    print(f"[fight]  {winner} beat {loser}")
                fighters_buf  = []
                fight_start_b = None
                continue

            loser = _extract_loser(line)
            if loser and fighters_buf:
                winner = next((f for f in fighters_buf if f != loser), None)
                if winner:
                    if print_fights:
                        print(f"[fight]  {winner} beat {loser}")
                    fighters_buf  = []
                    fight_start_b = None

        return len(lines)

    def _init_book_state(self):
        """Record current latest books so we only upload truly new arrivals."""
        self._last_weather_path = self._find_latest_book("TodaysWeather_*.txt")
        self._last_garden_path  = self._find_latest_book("GardeningAlmanac_*.txt")
        if self._last_weather_path:
            print(f"[books]  Weather baseline: {self._last_weather_path.name}")
        if self._last_garden_path:
            print(f"[books]  Garden  baseline: {self._last_garden_path.name}")
        print(f"[books]  Watching {BOOKS_DIR.name} for exported books, and "
              f"{PLAYER_LOG.name} for books opened in-game (no export needed) ...")

    # ── Player.log tail (deaths · loot · aurest · agrashab · blood · tips · books) ──

    def _init_player_log_state(self):
        """Baseline the Player.log tail position so the live loop only processes lines
        written from now on (forward-only, like the chat tail). Isolated in try/except
        so a missing or locked Player.log degrades only this feature, never aborts the
        whole monitor at construction time."""
        try:
            self._player_log_pos = PLAYER_LOG.stat().st_size if PLAYER_LOG.exists() else 0
            print(f"[player] Watching {PLAYER_LOG.name} for deaths / loot / world reports "
                  f"(pos={self._player_log_pos:,}) ...")
        except Exception as e:   # setup must never crash the monitor
            print(f"[player] init failed (Player.log watch disabled this run): {e}", file=sys.stderr)

    def _check_player_log(self):
        """Poll Player.log for newly written lines and dispatch them to the deaths /
        loot / aurest / agrashab / blood / tips / current-fight / world-report-book
        scanners. Whole body is wrapped: a parse/IO/network error here must never break
        the monitor loop (it runs every second alongside chat + book polling)."""
        now = time.time()
        if now - self._last_player_log_check < 5:
            return
        self._last_player_log_check = now
        try:
            if not PLAYER_LOG.exists():
                return
            size = PLAYER_LOG.stat().st_size
            if size < self._player_log_pos:        # log rotated/truncated on game restart
                self._player_log_pos = 0
                self.reset_loot_state()            # new session reuses entity ids
            if size <= self._player_log_pos:
                return
            with open(PLAYER_LOG, 'rb') as f:
                f.seek(self._player_log_pos)
                data = f.read()
            # Only process up to the last complete line; a half-written final line
            # stays buffered (pos not advanced past it) and is re-read next poll.
            # Splitting on b'\n' is UTF-8 safe — 0x0A never occurs mid-codepoint.
            nl = data.rfind(b'\n')
            if nl == -1:                            # no complete line buffered yet
                return
            self._player_log_pos += nl + 1
            chunk = data[:nl + 1].decode('utf-8', errors='replace')

            # Active character (login), so mob-drop observations below are attributed
            # to the toon at the keyboard. Last login in the chunk wins.
            logins = LOGIN_RE.findall(chunk)
            if logins:
                self._active_char = logins[-1]

            # Each scanner below is isolated in its own try/except so a parse/merge/IO
            # error in one can't reach the outer handler and starve the others of this
            # chunk after the log cursor has already advanced past it.

            # Death XP: recorded locally AND pushed to the site death-XP overlay.
            try:
                self._record_deaths(self._scan_deaths(chunk))
            except Exception as e:
                print(f"[death]  record failed (skipping this chunk): {e}", file=sys.stderr)

            # Mob drops → community bestiary observed-drop DB (POST /api/loot).
            try:
                self._scan_loot(chunk)
            except Exception as e:
                print(f"[loot]   scan failed (skipping this chunk): {e}", file=sys.stderr)

            # Daily community world reports: Aurest mission, Agrashab bounty, blood
            # target, casino hot tips, current arena fight.
            try:
                self._scan_aurest(chunk)
            except Exception as e:
                print(f"[aurest] scan failed (skipping this chunk): {e}", file=sys.stderr)
            try:
                self._scan_agrashab(chunk)
            except Exception as e:
                print(f"[agrashab] scan failed (skipping this chunk): {e}", file=sys.stderr)
            try:
                self._scan_blood(chunk)
            except Exception as e:
                print(f"[blood]  scan failed (skipping this chunk): {e}", file=sys.stderr)
            try:
                self._scan_hot_tips(chunk)
            except Exception as e:
                print(f"[hot-tip] scan failed (skipping this chunk): {e}", file=sys.stderr)
            try:
                self._scan_next_fight(chunk)
            except Exception as e:
                print(f"[fight]  scan failed (skipping this chunk): {e}", file=sys.stderr)
            try:
                self._scan_bets(chunk)
            except Exception as e:
                print(f"[bet]    scan failed (skipping this chunk): {e}", file=sys.stderr)
            # Weather / garden / warden books, straight from the in-game read —
            # no Books/ export needed.
            try:
                self._scan_books(chunk)
            except Exception as e:
                print(f"[books]  scan failed (skipping this chunk): {e}", file=sys.stderr)
            # Save wanted books to Books/ so the in-game export click can be skipped.
            # Separate from _scan_books: saving is local and must keep working on a
            # world the site doesn't track (where world_reports is off).
            try:
                self._save_books(chunk)
            except Exception as e:
                print(f"[books]  save failed (skipping this chunk): {e}", file=sys.stderr)
        except Exception as e:   # the Player.log tail must never break the monitor loop
            print(f"[player] check failed (skipping this poll): {e}", file=sys.stderr)

    # ── Which world am I on? (Player.log connection → server name) ──────────────
    def _detect_server(self):
        """Work out which Project Gorgon world you're on from Player.log: the client
        logs its server list ("Url"/"Name"), then the host it connects to
        (connected, url=sN.projectgorgon.com). Map host -> Name. Returns a lowercase
        server name (strekios/miraverre/…), or None if it can't tell. Reads only up to
        the first connection (near the top of a session's log), so it's cheap."""
        try:
            if not PLAYER_LOG.exists():
                return None
            url_to_name = {}
            with open(PLAYER_LOG, 'r', encoding='utf-8', errors='replace') as f:
                for line in f:
                    if '"Url"' in line and '"Name"' in line:
                        m = re.search(r'"Url"\s*:\s*"([^"]+)".*?"Name"\s*:\s*"([^"]+)"', line)
                        if m:
                            url_to_name[m.group(1)] = m.group(2)
                    elif 'connected, url=' in line:
                        m = re.search(r'connected, url=([^,\s]+)', line)
                        if m and m.group(1) in url_to_name:
                            return url_to_name[m.group(1)].lower()
            return None
        except Exception:
            return None

    def _reporting_schema(self):
        """GET /api/reporting-schema once per run, cached; {} if the site is unreachable.

        Two things need it — which worlds the site tracks, and the arena roster — and a
        player's connection may be slow, so it is fetched once rather than per question.
        """
        if self._schema is None:
            self._schema = {}
            try:
                url = self.server_url.rstrip('/') + '/api/reporting-schema'
                req = urllib.request.Request(
                    url, headers={'User-Agent': f'GorgonChatMonitor/{__version__}'})
                with urllib.request.urlopen(req, timeout=10) as resp:
                    got = json.loads(resp.read().decode('utf-8'))
                if isinstance(got, dict):
                    self._schema = got
            except Exception:
                pass        # unreachable/older server: every caller falls back
        return self._schema

    def _refresh_fighters(self):
        """Take the arena roster from the site, falling back to the built-in maps."""
        if _apply_fighter_schema(self._reporting_schema().get('fighters')):
            print(f"[arena] roster from the site: {', '.join(sorted(set(_FIGHTERS.values())))}")

    def _world_is_tracked(self):
        """Does the site keep per-server data for the world we're logged into?

        Beta (beta.projectgorgon.com) is a real world the client connects to and NOT
        one the site tracks, so every per-server endpoint answers `?server=beta` with
        a bare `abort(400)` — reporting anyway costs a rejected request per book read
        or blood scan, and beta values would be plain WRONG for live players if they
        ever did land. /api/reporting-schema is the authority (a world added there
        needs no client edit); VALID_GAME_SERVERS is the offline fallback."""
        worlds = VALID_GAME_SERVERS
        live = self._reporting_schema().get('servers')
        if isinstance(live, list) and live:
            worlds = {str(s).lower() for s in live}
        if self.game_server in worlds:
            return True
        print(f"[server] '{self.game_server}' is not a world this site tracks "
              f"({', '.join(sorted(worlds))}). World reporting is OFF for this "
              f"session (weather, garden, warden, blood, aurest, agrashab, casino "
              f"tips). Everything else still runs.", file=sys.stderr)
        return False

    def _world_report_off(self, kind):
        """True when a per-world report must be skipped, logging once per event."""
        if self.world_reports:
            return False
        print(f"[{kind}] not reported: {self.game_server} is not tracked by the site")
        return True

    # ── Aurest mission (Lady Alethina's conversation → /nb/aurest/set) ──────────
    def _aurest_from_preface(self, qid):
        """A preface quest id -> its Aurest mission name, or None if it isn't an
        Aurest mission. Resolves through quests.json (works for every quest / any
        future mission), falling back to the built-in map when the catalogue is
        unavailable."""
        qid = int(qid)
        name = (self._quest_catalogue().get(f'quest_{qid}') or {}).get('Name', '')
        if name.startswith(AUREST_PREFIX):
            return name[len(AUREST_PREFIX):].strip()
        return AUREST_MISSIONS.get(qid)

    def _scan_aurest(self, chunk):
        """Report the day's Aurest mission if Lady Alethina's preface appears in this
        chunk. Last match wins; de-duped so the same mission is reported once."""
        mission = None
        for m in AUREST_PREFACE_RE.finditer(chunk):
            found = self._aurest_from_preface(m.group(1))
            if found:
                mission = found
        if mission and mission != self._last_aurest:
            self._last_aurest = mission
            print(f"[aurest] Lady Alethina's mission today: {mission}")
            self._report_aurest(mission)

    def _init_aurest_state(self):
        """Backfill: find the most recent Aurest preface already in Player.log and
        report it, so the current day's mission is picked up even if Lady Alethina was
        visited before the monitor started. Isolated: never aborts construction."""
        self._last_aurest = None
        try:
            if not PLAYER_LOG.exists():
                return
            last = None
            with open(PLAYER_LOG, 'r', encoding='utf-8', errors='replace') as f:
                for line in f:
                    if '_Preface>>>' not in line:
                        continue
                    m = AUREST_PREFACE_RE.search(line)
                    if m:
                        found = self._aurest_from_preface(m.group(1))
                        if found:
                            last = found
            if last:
                print(f"[aurest] Today's mission (from Lady Alethina's log): {last}")
                self._last_aurest = last
                self._report_aurest(last)
        except Exception as e:
            print(f"[aurest] init failed (skipping): {e}", file=sys.stderr)

    def _report_aurest(self, mission):
        """Community-report the mission to /nb/aurest/set (validated + rate-limited
        server-side). GET, per game server."""
        if self._world_report_off('aurest'):
            return
        url = (self.server_url.rstrip('/') + '/nb/aurest/set?m='
               + urllib.parse.quote(mission) + '&server=' + urllib.parse.quote(self.game_server))
        try:
            with urllib.request.urlopen(url, timeout=15) as resp:
                print(f"[aurest] reported -> {resp.read().decode('utf-8', 'replace').strip()}")
            global REPORTS_MADE          # these two report by GET, not post_json
            REPORTS_MADE += 1
        except Exception as e:
            print(f"[aurest] report failed: {e}", file=sys.stderr)

    # ── Agrashab's daily bounty (Fae Realm god's preface → /nb/agrashab/set) ────
    def _agrashab_from_preface(self, qid):
        """A preface quest id -> its Agrashab bounty target, or None if it isn't an
        Agrashab bounty. Resolves through quests.json (works for any future target),
        falling back to the built-in map when the catalogue is unavailable."""
        qid = int(qid)
        name = (self._quest_catalogue().get(f'quest_{qid}') or {}).get('Name', '')
        if name.startswith(AGRASHAB_PREFIX):
            return name[len(AGRASHAB_PREFIX):].strip()
        return AGRASHAB_TARGETS.get(qid)

    def _scan_agrashab(self, chunk):
        """Report the day's Agrashab bounty if the god's preface appears in this chunk.
        Last match wins; de-duped so the same target is reported once."""
        target = None
        for m in AUREST_PREFACE_RE.finditer(chunk):     # generic <<<quest_N_Preface>>>
            found = self._agrashab_from_preface(m.group(1))
            if found:
                target = found
        if target and target != self._last_agrashab:
            self._last_agrashab = target
            print(f"[agrashab] Today's bounty: {target}")
            self._report_agrashab(target)

    def _init_agrashab_state(self):
        """Backfill: find the most recent Agrashab preface already in Player.log and
        report it, so today's bounty is picked up even if the god was visited before the
        monitor started. Isolated: never aborts construction."""
        self._last_agrashab = None
        try:
            if not PLAYER_LOG.exists():
                return
            last = None
            with open(PLAYER_LOG, 'r', encoding='utf-8', errors='replace') as f:
                for line in f:
                    if '_Preface>>>' not in line:
                        continue
                    m = AUREST_PREFACE_RE.search(line)
                    if m:
                        found = self._agrashab_from_preface(m.group(1))
                        if found:
                            last = found
            if last:
                print(f"[agrashab] Today's bounty (from the log): {last}")
                self._last_agrashab = last
                self._report_agrashab(last)
        except Exception as e:
            print(f"[agrashab] init failed (skipping): {e}", file=sys.stderr)

    def _report_agrashab(self, target):
        """Community-report the bounty to /nb/agrashab/set (validated + rate-limited
        server-side). GET, per game server."""
        if self._world_report_off('agrashab'):
            return
        url = (self.server_url.rstrip('/') + '/nb/agrashab/set?m='
               + urllib.parse.quote(target) + '&server=' + urllib.parse.quote(self.game_server))
        try:
            with urllib.request.urlopen(url, timeout=15) as resp:
                print(f"[agrashab] reported -> {resp.read().decode('utf-8', 'replace').strip()}")
            global REPORTS_MADE          # these two report by GET, not post_json
            REPORTS_MADE += 1
        except Exception as e:
            print(f"[agrashab] report failed: {e}", file=sys.stderr)

    # ── Vampire blood target (Player.log "Current Blood Season" → /api/blood-target) ──
    def _scan_blood(self, chunk):
        """Report the day's blood target from the 'Current Blood Season' talk screen.
        Last match wins; de-duped so the same target reports once."""
        target = None
        for m in BLOOD_SEASON_RE.finditer(chunk):
            target = m.group(1).strip()
        if target and target != self._last_blood:
            self._last_blood = target
            print(f"[blood]  Today's blood target: {target}")
            self._update_blood_target(target)

    def _init_blood_state(self):
        """Backfill: report the most recent blood target already in Player.log, so
        today's is picked up even if seen before the monitor started."""
        self._last_blood = None
        try:
            if not PLAYER_LOG.exists():
                return
            last = None
            with open(PLAYER_LOG, 'r', encoding='utf-8', errors='replace') as f:
                for line in f:
                    if 'Current Blood Season' not in line:
                        continue
                    m = BLOOD_SEASON_RE.search(line)
                    if m:
                        last = m.group(1).strip()
            if last:
                print(f"[blood]  Today's blood target (from log): {last}")
                self._last_blood = last
                self._update_blood_target(last)
        except Exception as e:
            print(f"[blood]  init failed (skipping): {e}", file=sys.stderr)

    # ── Casino hot tips (Player.log NPC reads → /api/gambling/tip) ─────────────
    @staticmethod
    def _eastern_date(dt_utc):
        """Eastern (America/New_York) calendar date of a UTC datetime, DST-aware.
        Mirrors server.py's _eastern_date_of, with the same crude fallback."""
        try:
            from zoneinfo import ZoneInfo
            return dt_utc.astimezone(ZoneInfo('America/New_York')).date()
        except Exception:
            off = -4 if 3 <= dt_utc.month <= 11 else -5
            return (dt_utc + datetime.timedelta(hours=off)).date()

    def _tip_casino_day(self, hms):
        """Eastern casino-day for a Player.log UTC 'HH:MM:SS' stamp, or None.

        Player.log stamps are UTC (confirmed: its last line matches `date -u` to the
        second, while ChatLogs are local). The casino rolls over at midnight Eastern
        (= 04:00 UTC in EDT, 05:00 in EST). We rebuild the full UTC instant from the
        current UTC date — rolling back a day when the time-of-day is in the future,
        i.e. it belongs to the previous UTC date — then read its Eastern date. A
        pre-turnover read (e.g. 00:42 UTC) lands on the PREVIOUS Eastern day, so it is
        never mistaken for a current tip. This is the "don't look past 04:00" cutoff."""
        try:
            t = datetime.datetime.strptime(hms, "%H:%M:%S").time()
        except ValueError:
            return None
        now = datetime.datetime.now(datetime.timezone.utc)
        cand = now.replace(hour=t.hour, minute=t.minute, second=t.second, microsecond=0)
        if cand > now + datetime.timedelta(minutes=5):
            cand -= datetime.timedelta(days=1)
        return self._eastern_date(cand)

    def _parse_hot_tip_key(self, text):
        """(gambling-tip key, human label) for a casino hot-tip read, or (None, None).
        Edge tips (upper-hand matchups) → 'edge:winner:loser'; solo tips → 'plus5:X' /
        'minus5:X' by sign. Edge is tried first; an edge-shaped line we can't fully
        parse returns None rather than being misread as a solo tip."""
        m = _HOT_TIP_EDGE_RE.search(text)
        if m:
            a = _FIGHTERS[m.group(1).lower()]
            b = _FIGHTERS[m.group(2).lower()]
            winner = _FIGHTERS[m.group(3).lower()]
            loser = b if winner == a else a
            if loser == winner:
                return None, None
            return f"edge:{winner}:{loser}", f"{winner} over {loser}  +10%"
        if 'upper hand' in text.lower():
            return None, None          # edge-shaped but unparsed — don't solo-misfire
        m = _HOT_TIP_SOLO_RE.search(text)
        if m:
            name = _FIGHTERS[m.group(1).lower()]
            pct = int(m.group(2))
            if pct > 0:
                return f"plus5:{name}", f"{name}  +5%"
            if pct < 0:
                return f"minus5:{name}", f"{name}  -5%"
        return None, None

    def _scan_hot_tips(self, chunk):
        """Parse casino 'hot tip' NPC reads in this Player.log chunk and report the
        ones on the CURRENT casino day. Tips stamped before today's midnight-Eastern
        turnover (00:00-03:59 UTC in summer) belong to yesterday's casino and are
        skipped — the server stamps tips on receipt, so a stale one would be mis-dated
        onto today. De-duped per casino day."""
        today = self._eastern_date(datetime.datetime.now(datetime.timezone.utc))
        if self._tips_day != today:            # new casino day → fresh dedup set
            self._tips_day = today
            self._tips_seen = set()
        for line in chunk.splitlines():
            if not _HOT_TIP_LINE_RE.search(line):
                continue
            ts = _PLAYERLOG_TS_RE.match(line)
            if not ts or self._tip_casino_day(ts.group(1)) != today:
                continue                        # stale: before today's turnover
            key, label = self._parse_hot_tip_key(line)
            if not key or key in self._tips_seen:
                continue
            self._tips_seen.add(key)
            print(f"[hot-tip] casino read: {label}  → {key}")
            self._report_gambling_tip(key)

    def _init_hot_tips_state(self):
        """Backfill: report today's casino hot tips already in the tail of Player.log,
        so a read seen just before the monitor started still reaches the board. Bounded
        to the recent tail (a time-of-day stamp alone can't date multi-day-old lines)
        and filtered by the same casino-day cutoff. Isolated: never aborts construction."""
        try:
            if not PLAYER_LOG.exists():
                return
            size = PLAYER_LOG.stat().st_size
            window = 262144
            with open(PLAYER_LOG, 'rb') as f:
                f.seek(max(0, size - window))
                tail = f.read().decode('utf-8', errors='replace')
            if size > window:
                tail = tail.split('\n', 1)[-1]   # drop the partial leading line
            self._scan_hot_tips(tail)
        except Exception as e:
            print(f"[hot-tip] init failed (skipping): {e}", file=sys.stderr)

    def _report_gambling_tip(self, key):
        """POST an authoritative casino tip to /api/gambling/tip (community board, not
        auth-gated; the server de-dups, budgets, and stamps the Eastern date). Per game
        server. Isolated: a report failure must never break the monitor loop."""
        if self._world_report_off('hot-tip'):
            return
        url = (self.server_url.rstrip('/') + '/api/gambling/tip?server='
               + urllib.parse.quote(self.game_server))
        body = json.dumps({'key': key, 'value': True}).encode('utf-8')
        req = urllib.request.Request(
            url, data=body, headers={'Content-Type': 'application/json'}, method='POST')
        try:
            with urllib.request.urlopen(req, timeout=15) as resp:
                resp.read()
            print(f"[hot-tip] reported → {key}")
        except Exception as e:
            print(f"[hot-tip] report failed ({key}): {e}", file=sys.stderr)
        self._mirror_redwing_tip(key)

    def _mirror_redwing_tip(self, key):
        """Forward a reported casino tip to redwing.bet (POST /api/v1/tips, Bearer
        GORGON_KEY). OFF unless the token is set. Isolated: any failure here is logged
        and swallowed so it can never affect the gorgonzola report or the monitor loop."""
        if not REDWING_TOKEN:
            return
        payload = _redwing_payload(key)
        if payload is None:
            return
        try:
            res = post_json(REDWING_URL, '/api/v1/tips', payload,
                            headers={'Authorization': f'Bearer {REDWING_TOKEN}',
                                     'User-Agent': REDWING_UA})
            if res is None or res.get('status', 200) >= 400:
                print(f"[hot-tip] redwing mirror failed ({key}): {res}", file=sys.stderr)
            else:
                print(f"[hot-tip] redwing ← {key}")
        except Exception as e:
            print(f"[hot-tip] redwing mirror error ({key}): {e}", file=sys.stderr)

    # ── Current arena fight (Kyuroma the bookie → /api/current-fight) ───────────
    def _scan_next_fight(self, chunk):
        """Parse the bookie's 'next fight will be between X and Y … begins in N' talk
        screen and report the live matchup. Forward-only (no tail backfill) — a stale
        countdown would resolve to a fight that's already over. Re-reports only on a
        matchup change or after the last report has aged out, so an unchanged screen
        refreshing every ~30s doesn't spam the endpoint."""
        for line in chunk.splitlines():
            m = _NEXT_FIGHT_RE.search(line)
            if not m:
                continue
            a = _FIGHTERS[m.group(1).lower()]
            b = _FIGHTERS[m.group(2).lower()]
            if a == b:
                continue
            secs = _parse_fight_countdown(line)
            now = time.monotonic()
            if (a, b) == self._fight_last and (now - self._fight_last_at) < 300:
                continue
            self._fight_last, self._fight_last_at = (a, b), now
            print(f"[fight]  Current fight: {a} vs {b} (starts in {secs // 60}m {secs % 60}s)")
            self._report_current_fight(a, b, secs)

    def _report_current_fight(self, a, b, secs):
        """POST the bookie's live matchup to /api/current-fight (community-set, per game
        server; the server resolves the countdown to a start time on receipt). Isolated:
        a report failure — including the expected 429 cooldown — never breaks the loop."""
        if self._world_report_off('fight'):
            return
        url = (self.server_url.rstrip('/') + '/api/current-fight?server='
               + urllib.parse.quote(self.game_server))
        body = json.dumps({'a': a, 'b': b, 'starts_in_secs': secs}).encode('utf-8')
        req = urllib.request.Request(
            url, data=body, headers={'Content-Type': 'application/json'}, method='POST')
        try:
            with urllib.request.urlopen(req, timeout=15) as resp:
                resp.read()
            print(f"[fight]  reported → {a} vs {b}")
        except Exception as e:
            if getattr(e, 'code', None) != 429:   # 429 = per-server cooldown, expected
                print(f"[fight]  report failed ({a} vs {b}): {e}", file=sys.stderr)
        self._mirror_redwing_live_fight(a, b, secs)

    def _mirror_redwing_live_fight(self, a, b, secs):
        """Report the live matchup to redwing.bet (POST /api/live/report, Bearer
        LIVE_REPORT_SECRET) using its numeric fighter IDs + an epoch-ms start time
        (now + the bookie's countdown). OFF unless the secret is set. Isolated: any
        failure is logged and swallowed so it never touches the gorgonzola report."""
        if not LIVE_REPORT_SECRET:
            return
        aid, bid = REDWING_FIGHTER_IDS.get(a), REDWING_FIGHTER_IDS.get(b)
        if not aid or not bid:
            print(f"[fight]  redwing live: no id for {a}/{b}", file=sys.stderr)
            return
        payload = {'fighter_a_id': aid, 'fighter_b_id': bid,
                   'started_at': int((time.time() + secs) * 1000),
                   'reporter': REDWING_REPORTER or self.game_server}
        try:
            res = post_json(REDWING_URL, '/api/live/report', payload,
                            headers={'Authorization': f'Bearer {LIVE_REPORT_SECRET}',
                                     'User-Agent': REDWING_UA})
            if res is None or res.get('status', 200) >= 400:
                print(f"[fight]  redwing live failed ({a} vs {b}): {res}", file=sys.stderr)
            else:
                print(f"[fight]  redwing live ← {a} vs {b}")
        except Exception as e:
            print(f"[fight]  redwing live error ({a} vs {b}): {e}", file=sys.stderr)

    # ── Quest catalogue (shared by the Aurest / Agrashab preface resolvers) ─────
    def _quest_catalogue(self):
        """Lazy quests.json ({quest_<id>: {...}}) for resolving preface quest ids."""
        if self._quests_cat is None:
            try:
                self._quests_cat = json.loads(
                    (Path(__file__).parent / 'quests.json').read_text(encoding='utf-8'))
            except Exception:
                self._quests_cat = {}
        return self._quests_cat

    # ── Arena betting (Kyuroma → community win/loss ratios, POST /api/bets) ─────
    def _scan_bets(self, chunk):
        """Detect placed-and-resolved arena bets in a Player.log chunk and report each
        resolved bet. Processed line-by-line in log order so the Confirm -> Placed ->
        Outcome sequence is respected. Only the matchup + win/loss is kept."""
        for line in chunk.splitlines():
            m = BET_CONFIRM_RE.search(line)
            if m:
                self._bet_pending = {'fighter': m.group(1).strip(),
                                     'opponent': m.group(2).strip()}
                continue
            m = BET_PLACED_RE.search(line)
            if m:
                fighter = m.group(1).strip()
                p = self._bet_pending
                self._bet_open = (dict(p) if p and p.get('fighter') == fighter
                                  else {'fighter': fighter, 'opponent': None})
                self._bet_pending = None
                print(f"[bet]    placed on {fighter}" +
                      (f" vs {self._bet_open['opponent']}" if self._bet_open.get('opponent') else ''))
                continue
            if BET_WIN_RE.search(line):
                self._resolve_bet(True)
                continue
            if BET_LOSS_RE.search(line):
                self._resolve_bet(False)

    def _resolve_bet(self, won):
        """Resolve the open placed bet with its win/loss outcome and report it. An
        outcome with no known placed bet is skipped — the outcome screen names neither
        the fighter nor the matchup, so it can't be attributed or bucketed by edge."""
        b = self._bet_open
        self._bet_open = None
        if not b:
            # The bet was placed before this monitor run began (started mid-session) or
            # the client restarted (state reset on log rotation). Logged, not recorded,
            # so every stored bet keeps a known matchup.
            print(f"[bet]    {'won' if won else 'lost'} outcome seen but no placed bet on "
                  f"record, skipped (monitor started after the bet, or client restarted)")
            return
        ts = datetime.datetime.now().isoformat(timespec='seconds')
        rec = {'fighter': b['fighter'], 'opponent': b.get('opponent'), 'won': won, 'ts': ts,
               'id': f"{ts}|{b['fighter']}|{b.get('opponent')}|{int(won)}"}
        print(f"[bet]    {'WON' if won else 'LOST'}: {b['fighter']}" +
              (f" vs {b['opponent']}" if b.get('opponent') else ''))
        self._report_bets([rec])

    def _report_bets(self, records):
        """Push resolved arena bets to the site (/api/bets) for community win/loss
        ratios. Skipped on a world the site doesn't track. Wrapped: a network problem
        never breaks the monitor (a rare missed bet is acceptable)."""
        if self._world_report_off('bet'):
            return
        payload = {'char': self._active_char, 'bets': records}
        headers = {'Authorization': f'Bearer {BETS_TOKEN}'} if BETS_TOKEN else None
        try:
            res = post_json(self.server_url, f'/api/bets?server={self.game_server}',
                            payload, headers=headers)
            if res is not None and res.get('ok'):
                print(f"[bet]    reported {res.get('added', 0)} resolved bet(s)")
        except Exception as e:
            print(f"[bet]    report failed: {e}", file=sys.stderr)

    # ── Mob-drop capture (Player.log → community bestiary observed-drop DB) ─────
    # Stateful, line-by-line: track the open corpse (mob name + entity id), pair each
    # looted item's ProcessAddItem(..,True) with its confirming ProcessRemoveLoot(sameId),
    # and buffer:
    #   {type:'corpse', mob, corpse_id}       once per distinct looted corpse (the rate
    #                                         denominator; re-opening a corpse is skipped)
    #   {type:'loot',   mob, corpse_id, item} per confirmed drop, attributed to the
    #                                         current corpse (RemoveLoot id-match is the
    #                                         real guard; LIFO handles multi-corpse melee)
    # Events are flushed in batches by _flush_loot. The server aggregates by mob into
    # drop counts + rate. Never per-line push.

    def reset_loot_state(self):
        """Clear cross-line loot state — called on log rotation (a new session reuses
        entity ids, so a stale open-corpse/seen-eid set would misattribute). Also clears
        the cross-line arena-bet state so an outcome from a pre-restart bet can't attach
        to a stale pending bet."""
        self._loot_corpse = None
        self._loot_pending = {}
        self._loot_window_adds = {}
        self._loot_seen_corpses = set()
        self._loot_seen_harvest = set()
        self._bet_pending = None
        self._bet_open = None

    @staticmethod
    def _harvest_action(line):
        """The harvest action named in a corpse RESULT summary, or None for the plain
        opening/refresh screen. 'extracted X from the corpse' / 'butchered the corpse' /
        'skinned the corpse'."""
        for phrase, action in HARVEST_VERBS:
            if phrase in line:
                return action
        if 'extracted ' in line and 'from the corpse' in line:
            return 'extract'
        return None

    def _emit_harvest(self, mob, eid, action):
        """A harvest RESULT summary fired: the AddItem(True) items still open in this
        window (i.e. not taken as base loot via RemoveLoot) are this action's output.
        Emit one 'action' marker (the per-action rate denominator) + a 'loot' event per
        item tagged with the action, then clear the window buffer so the next action on
        the same corpse starts fresh."""
        self._loot_buffer.append({
            'type': 'action', 'char': self._active_char,
            'mob': mob, 'corpse_id': eid, 'action': action,
        })
        for iname in self._loot_window_adds.values():
            self._loot_buffer.append({
                'type': 'loot', 'action': action, 'char': self._active_char,
                'mob': mob, 'corpse_id': eid, 'item': iname, 'zone': self._loot_zone,
                'at': int(time.time()),      # see the base-loot event for why
            })
        self._loot_window_adds = {}

    def _scan_loot(self, chunk):
        """Detect mob drops in a Player.log chunk and buffer them (batched by
        _flush_loot). Processed in log order so item→corpse attribution is temporal.

        Base loot is AddItem(...,True) confirmed by a matching RemoveLoot -> action 'loot'.
        Skin/butcher/extract never emit RemoveLoot, so they're captured from the corpse
        RESULT summary verb: every AddItem in the open window that WASN'T taken as base
        loot is attributed to that harvest action (see _emit_harvest)."""
        for line in chunk.splitlines():
            z = ZONE_RE.search(line)
            if z:
                self._loot_zone = z.group(1)
                continue
            m = CORPSE_RE.search(line)
            if m:
                eid, mob = m.group(1), m.group(2).strip()
                action = self._harvest_action(line)
                if action:                              # RESULT summary -> harvest output
                    key = (eid, action)                 # the summary line can repeat; count once
                    if key not in self._loot_seen_harvest:
                        self._loot_seen_harvest.add(key)
                        self._emit_harvest(mob, eid, action)
                else:                                   # opening/refresh -> (re)open window
                    prev = self._loot_corpse[0] if self._loot_corpse else None
                    self._loot_corpse = (eid, mob)
                    if eid != prev:                     # a NEW corpse gets a fresh window;
                        self._loot_window_adds = {}     # a refresh of the same one keeps it
                    if eid not in self._loot_seen_corpses:
                        self._loot_seen_corpses.add(eid)
                        self._loot_buffer.append({
                            'type': 'corpse', 'char': self._active_char,
                            'mob': mob, 'corpse_id': eid, 'zone': self._loot_zone,
                        })
                continue
            m = LOOT_ADD_RE.search(line)
            if m:
                iname, iid = m.group(1), m.group(2)
                self._loot_pending[iid] = iname
                self._loot_window_adds[iid] = iname     # candidate harvest output
                continue
            m = LOOT_REMOVE_RE.search(line)
            if m:
                iid = m.group(1)
                iname = self._loot_pending.pop(iid, None)
                self._loot_window_adds.pop(iid, None)   # taken as base loot, not a harvest
                if iname and self._loot_corpse:
                    eid, mob = self._loot_corpse
                    self._loot_buffer.append({
                        'type': 'loot', 'action': 'loot', 'char': self._active_char,
                        'mob': mob, 'corpse_id': eid, 'item': iname, 'zone': self._loot_zone,
                        # When this was SEEN. A failed push keeps its batch and retries,
                        # so a buffer can cross midnight; without this the site would
                        # file yesterday's kills under today's blood season.
                        'at': int(time.time()),
                    })
                continue

        if len(self._loot_buffer) >= LOOT_MAX_BUFFER:
            self._flush_loot(force=True)

    def _flush_loot(self, force=False):
        """Send buffered mob-drop events as one batch to /api/loot. Throttled to
        LOOT_FLUSH_SECS unless forced/full. On failure the buffer is kept (capped) and
        retried, so a brief outage never loses a drop. LOOT_TOKEN is sent as a Bearer
        header when set (placeholder — the endpoint is open for now).

        The world rides along because vampire loot names the day's blood target, and
        the site joins the two to correct a creature's blood type. It is a query param,
        not a payload field, so the endpoint keeps accepting an older monitor's batch
        unchanged; a world the site does not track is sent anyway and simply infers
        nothing there."""
        if not self._loot_buffer:
            return
        now = time.time()
        if not force and (now - self._last_loot_flush) < LOOT_FLUSH_SECS \
                and len(self._loot_buffer) < LOOT_MAX_BUFFER:
            return
        self._last_loot_flush = now
        payload = {'char': self._active_char, 'events': self._loot_buffer}
        headers = {'Authorization': f'Bearer {LOOT_TOKEN}'} if LOOT_TOKEN else None
        try:
            path = '/api/loot?server=' + urllib.parse.quote(self.game_server)
            res = post_json(self.server_url, path, payload, headers=headers)
        except Exception as e:
            res = None
            print(f"[loot]   push failed (will retry): {e}", file=sys.stderr)
        if res is not None and res.get('ok'):
            print(f"[loot]   pushed {res.get('accepted', 0)} drop event(s)")
            self._loot_buffer = []
        elif len(self._loot_buffer) > LOOT_BUFFER_CAP:
            self._loot_buffer = self._loot_buffer[-LOOT_BUFFER_CAP:]

    # ── Death XP research (Player.log → site death-XP overlay) ─────────────────
    # Records the death-XP the game awards per cause of death and pushes it to the
    # site so the unknown xp/rxp values in deaths.json can be filled in (live overlay
    # on the Dying page; deaths.json stays the hand-curated source of truth). Kept
    # locally too. deaths_observed.json maps each cause_of_death key ->
    # {name, first_xp, repeat_xp, xp_values, last_seen}: first_xp is the canonical
    # award (only ProcessDeathMessage firstTime=True carries it); repeat_xp is the
    # HIGHEST repeat award seen (firstTime=False, decays toward 0 so max is the useful
    # value). See _report_deaths_xp for the push.

    def _deaths_catalogue(self):
        """Lazy {cause_of_death key -> {name, xp}} from deaths.json, to label
        observations and flag which ones fill a currently-unknown (null) XP."""
        if self._deaths_cat is None:
            self._deaths_cat = {}
            try:
                data = json.loads((Path(__file__).parent / 'deaths.json').read_text(encoding='utf-8'))
                self._deaths_cat = {d['key']: d for d in data if d.get('key')}
            except Exception:
                pass
        return self._deaths_cat

    @staticmethod
    def _scan_deaths(text):
        """Death observations in `text` as [{key, xp, first}] (log order)."""
        return [{'key': key.lower(), 'xp': int(xp), 'first': first == 'True'}
                for key, xp, first in DEATH_RE.findall(text)]

    def _merge_deaths(self, observations):
        """Fold observations into self._deaths_map, tracking the canonical first-death
        XP (firstTime=True, set once) and the HIGHEST repeat award (firstTime=False —
        repeat XP decays toward 0, so max is the value worth keeping). Idempotent:
        re-scanning the full logs never lowers a value or corrupts the xp_values set.
        Returns the changed keys as push-ready aggregates [{key, first_xp, repeat_xp}]
        (a newly-learned first_xp or a new higher repeat_xp)."""
        cat = self._deaths_catalogue()
        changed = []
        for o in observations:
            ent = self._deaths_map.setdefault(o['key'], {
                'name': (cat.get(o['key']) or {}).get('name', ''),
                'first_xp': None, 'repeat_xp': None, 'xp_values': [], 'last_seen': '',
            })
            ent.setdefault('repeat_xp', None)  # backfill records written before this field
            note = False
            if o['xp'] not in ent['xp_values']:
                ent['xp_values'] = sorted(set(ent['xp_values']) | {o['xp']})
                note = True     # a value nobody had recorded is research in its own
                                # right: the ladder of repeat awards (50, 5, 1, 0) is
                                # what deaths.json's rxp is trying to describe, and it
                                # is invisible if only the highest is ever reported
            if o['first']:
                if ent['first_xp'] is None:
                    ent['first_xp'] = o['xp']; note = True
            elif ent['repeat_xp'] is None or o['xp'] > ent['repeat_xp']:
                ent['repeat_xp'] = o['xp']; note = True
            ent['last_seen'] = datetime.datetime.now().isoformat(timespec='seconds')
            if note:
                changed.append(o['key'])
        seen, out = set(), []
        for k in changed:
            if k in seen:
                continue
            seen.add(k)
            ent = self._deaths_map[k]
            out.append({'key': k, 'first_xp': ent['first_xp'], 'repeat_xp': ent['repeat_xp'],
                        'xp_values': list(ent['xp_values'])})
        return out

    def _write_deaths(self):
        """Save the local death-XP research file (deaths_observed.json), sorted so
        that opening it in an editor, or diffing it, stays readable."""
        try:
            ordered = dict(sorted(self._deaths_map.items()))
            self._deaths_file.write_text(json.dumps(ordered, indent=2, ensure_ascii=False),
                                         encoding='utf-8')
        except Exception as e:
            print(f"[death]  could not write {self._deaths_file.name}: {e}", file=sys.stderr)

    def _record_deaths(self, observations):
        """Merge live death observations, persist, log each, and push changed XP values
        to the site overlay."""
        if not observations:
            return
        changed = self._merge_deaths(observations)
        self._write_deaths()
        cat = self._deaths_catalogue()
        for o in observations:
            meta = cat.get(o['key']) or {}
            name = meta.get('name') or o['key']
            if o['first']:
                gap = ', FILLS A GAP in deaths.json' if meta.get('xp') is None else ''
                print(f"[death]  FIRST death: {name} = {o['xp']} XP (canonical){gap}")
            else:
                print(f"[death]  {name}: {o['xp']} XP (repeat)")
        self._report_deaths_xp(changed)

    def _report_deaths_xp(self, observations):
        """Contribute observed death XP to the community overlay (POST /api/death-xp),
        which fills deaths.json's null xp/rxp on the Dying page.

        Three numbers go up per cause: the canonical first-death XP, the highest repeat
        award, and `xp_values`, the whole set of awards seen for that cause. That last
        one is the research: repeat XP decays toward zero over successive deaths, so the
        ladder (50, 5, 1, 0) is the thing worth knowing, and reporting only the highest
        throws it away. Nobody dies of everything, which is exactly why this is worth
        pooling.

        The server keeps the max of each number and the union of the set, so re-pushing
        the same history, restarting, or two people reporting the same cause can never
        lower or lose a value. Wrapped so a network problem never breaks the monitor:
        the local record already stands, and a missed push goes out with the next one."""
        observations = [o for o in observations
                        if o and (o.get('first_xp') is not None or o.get('repeat_xp') is not None
                                  or o.get('xp_values'))]
        if not observations:
            return
        try:
            res = post_json(self.server_url, '/api/death-xp', {'observations': observations})
            if res is not None and res.get('ok'):
                print(f"[death]  pushed {res.get('updated', 0)} XP value(s) to the overlay")
        except Exception as e:
            print(f"[death]  push failed (recorded locally, will retry): {e}", file=sys.stderr)

    def _init_deaths_state(self):
        """Backfill the death-XP record from the full Player logs (idempotent), so
        history survives restarts and log rotation. Wrapped: never crash the monitor."""
        try:
            if self._deaths_file.exists():
                self._deaths_map = json.loads(self._deaths_file.read_text(encoding='utf-8'))
            for p in (PLAYER_PREV_LOG, PLAYER_LOG):
                if p.exists():
                    self._merge_deaths(self._scan_deaths(p.read_text(encoding='utf-8', errors='replace')))
            self._write_deaths()
            known = sum(1 for e in self._deaths_map.values() if e.get('first_xp') is not None)
            cat = self._deaths_catalogue()
            null_in_cat = sum(1 for k, e in self._deaths_map.items()
                              if e.get('first_xp') is not None
                              and (cat.get(k) or {}).get('xp') is None)
            print(f"[death]  Death-XP log: {len(self._deaths_map)} cause(s) seen, "
                  f"{known} with a canonical first-death XP "
                  f"({null_in_cat} fill a null in deaths.json) -> {self._deaths_file.name}")
            # Re-push everything (server keeps the max, so this can't corrupt), so any
            # value whose live push failed on a previous run reaches the overlay now.
            self._report_deaths_xp([
                {'key': k, 'first_xp': e.get('first_xp'), 'repeat_xp': e.get('repeat_xp')}
                for k, e in self._deaths_map.items()])
        except Exception as e:
            print(f"[death]  init failed (death logging disabled this run): {e}", file=sys.stderr)

    # ── Main loop ────────────────────────────────────────────────────────────

    def monitor_chat(self):
        """The main loop. Runs until you press Ctrl-C.

        Once a second: read whatever is new in today's chat log, then check the other
        watchers (exported books, Player.log, the queue of mob drops waiting to be
        sent). At midnight the game starts a new chat log, so the outer loop notices
        the date changed and picks up the new file.
        """
        while True:
            log_date = datetime.datetime.now().date()
            log_file = LOG_FOLDER / ("Chat-" + log_date.strftime("%y-%m-%d") + ".log")
            last_pos = log_file.stat().st_size if log_file.exists() else 0

            print(f"[monitor] Watching {log_file.name} (pos={last_pos:,}) ...")

            while datetime.datetime.now().date() == log_date:
                if log_file.exists():
                    with open(log_file, "r", encoding="utf-8", errors="replace") as f:
                        f.seek(last_pos)
                        new_lines = f.readlines()
                        last_pos = f.tell()
                    for line in new_lines:
                        self._process_line(line.strip())
                self._check_books()
                self._check_player_log()
                self._flush_loot()            # time-based batch flush (throttled)
                time.sleep(1)

            print(f"[monitor] Day rolled over.")
            self._current_fighters = []

    # ── Per-line processing ──────────────────────────────────────────────────

    def _process_line(self, line):
        """Handle one line of chat.

        Most lines are of no interest and fall straight through. The ones that matter
        are the casino's HotTips broadcasts and Kuzavek's arena commentary, which
        arrives a line at a time ("the far side is OTIS", then blows traded, then
        "GLOZ has fallen"), so who is fighting has to be remembered between lines.
        A fight that goes quiet for FIGHT_TIMEOUT is forgotten, otherwise a
        disconnection would leave two fighters standing in the ring forever.
        """
        if not line:
            return

        # Continuation line (no timestamp)
        if not TS_RE.match(line):
            if self._tip_block is not None:
                self._tip_block += '\n' + line
                if line.rstrip().endswith('"'):
                    self._parse_bulk_tips(self._tip_block)
                    self._tip_block = None
            return

        # New timestamped line — clear any unfinished tip block
        self._tip_block = None

        bt_m = BLOOD_TARGET_RE.search(line)
        if bt_m:
            self._update_blood_target(bt_m.group(1).strip())
            return

        if "[HotTips]" in line:
            msg = re.sub(r"^.*\[HotTips\]\s+\S+\s*", "", line)
            if BULK_TIP_START_RE.match(msg):
                if msg.rstrip().endswith('"'):
                    self._parse_bulk_tips(msg)
                else:
                    self._tip_block = msg
                return
            tip = _parse_fight_tip(msg)
            if tip:
                print(f"[fight-tip] {tip}")
            else:
                print(f"[tip]    {line}")
            return

        if "[NPC Chatter] Kuzavek:" not in line:
            return

        line_dt = _parse_line_ts(line)

        # Timeout: discard stale fight state
        if self._fight_start and line_dt:
            elapsed = (line_dt - self._fight_start).total_seconds()
            if elapsed > FIGHT_TIMEOUT:
                if self._current_fighters:
                    print(f"[fight]  Timeout ({elapsed:.0f}s), discarding {self._current_fighters}")
                self._current_fighters = []
                self._fight_start      = None

        fm = INTRO_RE.search(line)
        if fm:
            name = _canon_fighter(fm.group(1))
            if name and name not in self._current_fighters:
                if not self._current_fighters and line_dt:
                    self._fight_start = line_dt
                self._current_fighters.append(name)
            return

        winner = _extract_winner(line)
        if winner:
            loser = next((f for f in self._current_fighters if f != winner), "?")
            print(f"[fight]  {winner} beat {loser}")
            self._current_fighters = []
            self._fight_start      = None
            return

        loser = _extract_loser(line)
        if loser and self._current_fighters:
            winner = next((f for f in self._current_fighters if f != loser), None)
            if winner:
                print(f"[fight]  {winner} beat {loser}")
                self._current_fighters = []
                self._fight_start      = None

    # ── Blood target ─────────────────────────────────────────────────────────

    def _update_blood_target(self, name):
        """Report today's vampire blood target to the site for this world."""
        if self._world_report_off('blood'):
            return
        target = name if name else None
        result = post_json(self.server_url, f'/api/blood-target?server={self.game_server}', {'target': target})
        if result and result.get('ok'):
            print(f"[blood]  Blood target → {name or '(cleared)'}")
        else:
            print(f"[blood]  Update failed: {result}", file=sys.stderr)

    def _parse_bulk_tips(self, block):
        """Parse a multi-line HotTips block and emit fight-tip lines.

        Format 1 (sectioned): section headers "+5%:", "-10%:", "+10%:" followed by
          fighter names or "Winner > Loser" lines.
        Format 2 (freeform): lines like "Otis over Gloz +10%" with embedded percentages.
        Both formats can appear in the same block; Format 2 lines are caught as fallback.
        """
        lines = [l.strip() for l in block.split('\n') if l.strip()]
        if not lines:
            return
        if lines[0].startswith('"'):
            lines[0] = lines[0][1:]
        if lines[-1].endswith('"'):
            lines[-1] = lines[-1][:-1]

        current_pct = None
        for line in lines:
            if not line:
                continue
            # Header: "Hot Tips (N/M):" / "Hoht Tips (N/M):" etc.
            if re.match(r'\w[\w\s]*[Tt]ips\w*\s*\(\d+/\d+\):', line, re.IGNORECASE):
                continue
            # Separator
            if re.match(r'^-{3,}$', line):
                continue
            # Section header: "+5%:", "-10%:", "+10%:"
            m = re.match(r'^([+-]?\d+)%?\s*:', line)
            if m:
                current_pct = int(m.group(1))
                continue
            # Matchup: "Winner > Loser1, Loser2" — check before _parse_fight_tip
            # so multi-loser lines and section percentages are handled correctly.
            # Strip leading junk (e.g. "+15 Cor > Otis" → winner "Cor") for embedded-pct lines.
            if '>' in line:
                parts = line.split('>', 1)
                raw_w = re.sub(r'^[^A-Za-z]+', '', parts[0]).strip()
                winner = _FIGHTERS.get(raw_w.lower())
                if winner:
                    losers = [_FIGHTERS.get(re.sub(r"[^A-Za-z']+$", '', l.strip()).lower())
                              for l in parts[1].split(',')]
                    losers = [l for l in losers if l]
                    pct_str = ''
                    if current_pct is not None:
                        pct_str = f"  +{current_pct}%" if current_pct >= 0 else f"  {current_pct}%"
                    for loser in losers:
                        print(f"[fight-tip] {winner} over {loser}{pct_str}")
                else:
                    # fallback: line may have embedded pct in Format 2 style
                    tip = _parse_fight_tip(line)
                    if tip:
                        print(f"[fight-tip] {tip}")
                continue
            # Solo fighter: individual name in +5%/-5% section
            name = _FIGHTERS.get(line.lower())
            if name and current_pct is not None:
                sign = "+" if current_pct >= 0 else ""
                print(f"[fight-tip] {name}  {sign}{current_pct}%")
            elif not name:
                # fallback for any Format 2 freeform lines outside sections
                tip = _parse_fight_tip(line)
                if tip:
                    print(f"[fight-tip] {tip}")

    # ── Book watching ────────────────────────────────────────────────────────

    def _find_latest_book(self, pattern):
        """The most recently modified book in the Books folder matching a name
        pattern, e.g. TodaysWeather_*.txt, or None if you have never exported one."""
        files = sorted(BOOKS_DIR.glob(pattern), key=lambda p: p.stat().st_mtime)
        return files[-1] if files else None

    def _watch_book(self, kind, path):
        """Handle one newly-seen Books/ export; True once it needs no further
        attention — reported, or too stale to ever report. False leaves the path
        unbaselined so a rejected push is re-attempted (on _report_book's backoff)."""
        if not self.world_reports:
            return True     # world untracked: baseline it, don't poll it forever
        when = book_timestamp(path)
        if self._stale_book(path, when):
            return True
        return self._report_book(kind, path.read_text(encoding='utf-8'), when, path.name)

    def _stale_book(self, path, when):
        """True if this Books/ export is too old to report (and should be baselined).

        These books state their times RELATIVE to when they were read ("ends in 2
        hours"), and both this monitor and the server resolve that against the
        moment of upload — so a two-day-old export doesn't merely add nothing, it
        overwrites live garden/warden state with seasons that already ended. Only
        the Books/ watcher needs this: a book read in-game reaches _scan_books from
        Player.log within seconds. An unparseable filename is treated as fresh —
        the timestamp is the only age signal, and dropping a book we can't date
        would silently lose real reports."""
        if not when:
            return False
        age = (datetime.datetime.now() - when).total_seconds()
        if age <= BOOK_MAX_AGE_SECS:
            return False
        print(f"[books]  Skipping {path.name}: exported {age / 3600:.1f}h ago, "
              f"its relative times are no longer meaningful", file=sys.stderr)
        return True

    def _check_books(self):
        """Every ten seconds, look for a newly exported book in the Books folder.

        This is the second-best source and it is kept only for completeness: opening a
        book in game is already caught from Player.log (see _scan_books), which means
        you do not have to export anything at all. A book that arrives both ways is
        recognised as the same content and sent once.
        """
        now = time.time()
        if now - self._last_book_check < 10:
            return
        self._last_book_check = now

        # The Books/ watcher is now the SECOND source: opening a book in-game is
        # already caught from Player.log by _scan_books, and exporting it is an
        # extra manual step. It stays because an export is still a valid input —
        # _report_book de-dupes by content, so a book that arrives both ways is
        # only pushed once.
        w = self._find_latest_book("TodaysWeather_*.txt")
        if w and w != self._last_weather_path and self._watch_book('weather', w):
            self._last_weather_path = w

        g = self._find_latest_book("GardeningAlmanac_*.txt")
        if g and g != self._last_garden_path and self._watch_book('garden', g):
            self._last_garden_path = g

        wa = self._find_latest_book("ActiveWardenAlerts_*.txt")
        if wa and wa != self._last_warden_path and self._watch_book('warden', wa):
            self._last_warden_path = wa

    # ── World-report books: Player.log ProcessBook OR Books/*.txt ───────────────

    @staticmethod
    def _unescape_book(body):
        """Turn a ProcessBook body's literal backslash-escapes into real text.

        The log writes one book as ONE physical line, so its newlines arrive as
        the two characters \\ and n. Unescaped in a single pass — a naive
        .replace('\\\\n', '\\n') chain would also rewrite a genuine backslash
        followed by an n.
        """
        return re.sub(r'\\(.)',
                      lambda m: {'n': '\n', 't': '\t', 'r': '\r'}.get(m.group(1), m.group(1)),
                      body)

    def _report_book(self, kind, text, when, source):
        """Push one world-report book, whichever source it came from.

        Single funnel for the log scanner and the Books/ watcher, so the two can
        never drift in what they parse or how they report. De-dupes on the body
        itself: opening a book and then exporting it yields identical content,
        and that is one observation, not two. Content is the right key rather
        than a timestamp — a genuinely new weather book always differs, and a
        re-read warden alert differs too (its countdown moved), which is a
        fresher snapshot worth sending.

        For warden, last-sig alone is not enough: a delayed Books/ export of an
        OLDER snapshot arriving after a fresher in-game read would otherwise
        overwrite the server's fresher state. So warden also skips any snapshot
        whose sig was EVER sent (see _warden_sigs) — safe because a warden read
        never legitimately repeats (its countdown strictly decreases).

        A push the server REJECTS is retried, but on a backoff, never on the caller's
        own 10s poll: the Books/ watcher leaves a failed book's path unbaselined, so
        an unthrottled retry means ~360 identical rejected POSTs an hour (which is
        what a rate-limited garden book did). The wait is the server's own
        `retry_after` where it sends one, so the first retry lands as the cooldown
        expires rather than blind-polling through it.
        """
        sig = hashlib.sha1(text.strip().encode('utf-8', 'replace')).hexdigest()
        if self._book_sigs.get(kind) == sig:
            return False
        if kind == 'warden' and sig in self._warden_sigs:
            return False
        if time.time() < self._book_retry_at.get(kind, 0):
            return False
        handler = {'weather': self._upload_weather,
                   'garden':  self._upload_garden,
                   'warden':  self._upload_warden_alerts}[kind]
        ok = (handler(text, source) if kind == 'warden'
              else handler(text, when, source))
        if ok:
            self._book_sigs[kind] = sig
            self._book_retry_at.pop(kind, None)
            if kind == 'warden':
                self._warden_sigs.append(sig)
        else:
            wait = self._retry_hint or BOOK_RETRY_SECS
            self._book_retry_at[kind] = time.time() + wait
            print(f"[books]  {kind} push rejected, not retrying for {int(wait)}s",
                  file=sys.stderr)
        self._retry_hint = 0
        return ok

    def _post_book(self, endpoint, payload):
        """POST one book payload, noting how long the server asked us to wait.

        The upload handlers return a bool, so the 429's `retry_after` reaches
        _report_book through `_retry_hint` — set here, read and cleared there. One
        wrapper is the only place that side channel is written."""
        result = post_json(self.server_url, endpoint, payload)
        if isinstance(result, dict) and not result.get('ok'):
            try:
                self._retry_hint = max(0, int(result.get('retry_after') or 0))
            except (TypeError, ValueError):
                self._retry_hint = 0
        return result

    # ── Book auto-save (Player.log ProcessBook -> Books/*.txt) ─────────────────

    def _init_book_saving(self, save_books=None):
        """Resolve which books to auto-save and load the already-written set.

        Precedence: the --save-books argument, else GORGON_SAVE_BOOKS, else the
        SAVE_BOOKS_DEFAULT, which is every book. 'off'/'none' disables auto-saving.
        Wrapped like the other init helpers: a bad value or unreadable state file
        disables auto-saving for this run rather than crashing the monitor."""
        try:
            raw = save_books if save_books is not None else os.environ.get('GORGON_SAVE_BOOKS', '')
            raw = (raw or '').strip()
            if raw.lower() in ('off', 'none', '0', 'false'):
                self._save_titles = []
            elif raw.lower() == 'all':
                self._save_titles = ['*']
            elif raw:
                self._save_titles = [t.strip() for t in raw.split(',') if t.strip()]
            else:
                self._save_titles = ['*']   # SAVE_BOOKS_DEFAULT

            if self._books_written_file.exists():
                self._books_written = json.loads(
                    self._books_written_file.read_text(encoding='utf-8'))
            # What is being saved is announced by _print_summary, which runs next.
        except Exception as e:
            self._save_titles = []
            print(f"[books]  auto-save init failed (disabled this run): {e}", file=sys.stderr)

    def _write_books_state(self):
        """Persist the written-book signatures, newest kept if the cap is hit."""
        try:
            items = sorted(self._books_written.items(),
                           key=lambda kv: kv[1].get('seen', ''))[-_BOOKS_WRITTEN_MAX:]
            self._books_written = dict(items)
            self._books_written_file.write_text(
                json.dumps(self._books_written, indent=1, ensure_ascii=False), encoding='utf-8')
        except Exception as e:
            print(f"[books]  could not write {self._books_written_file.name}: {e}", file=sys.stderr)

    @staticmethod
    def _book_filename(title, when, export_name=''):
        """Name a saved book exactly as the game would.

        'Order Fulfilled' -> 'OrderFulfilled_260902_200023.txt'.

        The EXPORT NAME wins when the book carries one, because it is not always the
        title with the spaces taken out: both "Today's Shop Logs" and "Yesterday's Shop
        Logs" export as PlayerShopLog, and shop_pnl.html reads the year out of exactly
        that prefix (gorgon-shoplog.js parseYear), so a title-derived name would be a
        file it does not recognise. Books with no export name (the work orders) keep the
        title-derived name they have always had.
        """
        stem = re.sub(r'[^A-Za-z0-9]', '', export_name or '') or \
               re.sub(r'[^A-Za-z0-9]', '', title) or 'Book'
        return f"{stem}_{when.strftime('%y%m%d_%H%M%S')}.txt"

    def _wants_saving(self, title):
        """Is this book one you asked to have saved to disk? Matching is on a piece of
        the title, so 'Shop Logs' catches both today's and yesterday's."""
        titles = getattr(self, '_save_titles', [])
        if not titles:
            return False
        if titles == ['*']:
            return True
        low = title.lower()
        return any(t.lower() in low for t in titles)

    def _write_book_file(self, title, body, when, export_name=''):
        """Write one book exactly as the in-game save would: body only (no title, no
        timestamp — both live in the filename), CRLF line ends, markup intact.

        Returns the Path written, or None when it was already written or the write
        failed. Never overwrites: two books saved in the same second get _2, _3, …
        """
        name = self._book_filename(title, when, export_name)
        base, path, n = name[:-4], BOOKS_DIR / name, 1
        while path.exists():          # two books read in the same second
            n += 1
            path = BOOKS_DIR / f'{base}_{n}.txt'
        try:
            BOOKS_DIR.mkdir(parents=True, exist_ok=True)
            with open(path, 'w', encoding='utf-8', newline='\r\n') as f:
                f.write(body)
            return path
        except Exception as e:
            print(f"[books]  could not save {name}: {e}", file=sys.stderr)
            return None

    def _save_books(self, chunk):
        """Save every wanted book opened in this Player.log chunk to Books/.

        This is the save-step equivalent of what _scan_books does for world reports:
        reading the book in game is enough, no export click. The body is de-duped on
        its own content because re-opening a book logs an identical ProcessBook line,
        and for a work order a second file would double-book the cost downstream.

        "Now" is the book's time: the tail runs within seconds of the read, and the
        log line's own stamp is UTC while every saved book on disk is named in local
        time — deriving one from the other would misdate the file by the UTC offset.
        """
        if not getattr(self, '_save_titles', None):
            return
        wrote = False
        for m in BOOK_LOG_RE.finditer(chunk):
            title, body, export_name = m.group(1), m.group(2), (m.group(3) or '')
            if not self._wants_saving(title):
                continue
            text = self._unescape_book(body)
            sig = hashlib.sha1(f'{title}\n{text}'.strip().encode('utf-8', 'replace')).hexdigest()
            if sig in self._books_written:
                continue
            path = self._write_book_file(title, text, datetime.datetime.now(),
                                         export_name)
            if not path:
                continue
            self._books_written[sig] = {
                'title': title, 'file': path.name,
                'seen': datetime.datetime.now().strftime('%Y-%m-%dT%H:%M:%S'),
            }
            wrote = True
            print(f"[books]  saved {path.name}: {title}")
        if wrote:
            self._write_books_state()

    def _scan_books(self, chunk):
        """Report any world-report book OPENED in this Player.log chunk.

        This is what removes the export step: reading the book in-game is enough.
        """
        if not self.world_reports:
            return
        for m in BOOK_LOG_RE.finditer(chunk):
            title, body = m.group(1), m.group(2)
            kind = next((k for k, pat in BOOK_KINDS if pat.search(title)), None)
            if not kind:
                continue
            # The tail runs within seconds of the read, so "now" is the book's
            # reference time — and it is already naive LOCAL, which is what
            # parse_almanac_text wants. Deriving it from the log's own stamp
            # would mean rebuilding a UTC date the line doesn't carry.
            self._report_book(kind, self._unescape_book(body),
                              datetime.datetime.now(), f'Player.log ({title})')

    # ── Warden Alerts (Books/ActiveWardenAlerts_*.txt → /api/warden-alerts) ─────
    @staticmethod
    def _ends_at(ends):
        """'23 hours 48 minutes' -> absolute ISO-UTC end time, or '' if not parseable.
        Lets the site show a live countdown instead of the stale 'Ends in …' snapshot."""
        secs = 0
        for n, unit in re.findall(r'(\d+)\s*(day|hour|hr|minute|min)', ends or '', re.IGNORECASE):
            secs += int(n) * {'day': 86400, 'hour': 3600, 'hr': 3600, 'minute': 60, 'min': 60}[unit.lower()]
        if not secs:
            return ''
        return (datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=secs)).strftime('%Y-%m-%dT%H:%M:%SZ')

    def _parse_warden_alerts(self, text):
        """Parse an ActiveWardenAlerts book into [{npc, zone, threat, ends, ends_at}].
        Each alert is an <h2>Aid Request from NPC in Zone</h2> plus a threat sentence
        and an 'Ends in …' line. Threat is the phrase after 'assistance with'."""
        alerts = []
        for m in re.finditer(r'<h2>Aid Request from (.+?) in (.+?)</h2>(.*?)(?=<h2>|$)', text, re.DOTALL):
            npc, zone, rest = m.group(1).strip(), m.group(2).strip(), m.group(3)
            tm = re.search(r'assistance with (.+?)\.?\s*</indent>', rest, re.DOTALL)
            if tm:
                threat = tm.group(1).strip()
            else:
                bm = re.search(r'<indent=\d+>(.+?)</indent>', rest, re.DOTALL)
                threat = bm.group(1).strip() if bm else ''
            em = re.search(r'Ends in ([^<]+?)\s*</i>', rest)
            ends = em.group(1).strip() if em else ''
            alerts.append({'npc': npc, 'zone': zone, 'threat': threat,
                           'ends': ends, 'ends_at': self._ends_at(ends)})
        return alerts

    def _upload_warden_alerts(self, text, source):
        """Send the Warden Alerts book to the site. True if it was accepted."""
        alerts = self._parse_warden_alerts(text)
        if not alerts:
            print(f"[books]  no warden alerts parsed from {source}", file=sys.stderr)
            return False
        result = self._post_book(f'/api/warden-alerts?server={self.game_server}',
                                 {'alerts': alerts})
        if result and result.get('ok'):
            print(f"[books]  Warden alerts uploaded: {result['alerts']} active "
                  f"({', '.join(a['zone'] for a in alerts)})")
            return True
        print(f"[books]  Warden alert upload failed: {result}", file=sys.stderr)
        return False

    def _upload_weather(self, text, when, source):
        """Send today's weather (one line per zone) to the site. True if accepted."""
        ts = when.strftime('%Y-%m-%d %H:%M:%S') if when else '?'
        zones = parse_weather_text(text)
        if not zones:
            print(f"[books]  ERROR: no weather zones parsed from {source}", file=sys.stderr)
            return False
        result = self._post_book(
            f'/api/weather/save?server={self.game_server}',
            {'zones': [{'zone': z, 'weather': w} for z, w in zones]},
        )
        if result and result.get('ok'):
            print(f"[books]  Weather uploaded: {result['zones']} zones "
                  f"({result['danger']} danger, {result['safe']} safe)  [{ts}]")
            return True
        print(f"[books]  Weather upload failed: {result}", file=sys.stderr)
        return False

    def _upload_garden(self, text, when, source):
        """Send the gardening almanac to the site. True if accepted.

        The book states its times relative to when you read it ("ends in 2 hours"),
        never as a clock time, so the moment it was read has to travel with it. That
        is what `when` is.
        """
        # parse_almanac_text resolves the book's relative times ("ends in 2h")
        # against this reference, and book_timestamp yields a NAIVE LOCAL time
        # from the filename — so the log path must hand it local time too, never
        # the raw UTC log stamp, or every garden window lands hours off.
        file_time = when or datetime.datetime.now()
        ts = file_time.strftime('%Y-%m-%d %H:%M:%S')
        events = parse_almanac_text(text, file_time)
        if not events:
            print(f"[books]  WARNING: no garden events parsed from {source}", file=sys.stderr)
            return False
        now_ms = time.time() * 1000
        result = self._post_book(
            f'/api/garden/save?server={self.game_server}',
            {'events': [
                {
                    'crop':     e['crop'].lower(),
                    'zone':     e['zone'],
                    'active':   e['active'],
                    'dur_secs': max(0, int((e['abs_ts_ms'] - now_ms) / 1000)),
                }
                for e in events
            ]},
        )
        if result and result.get('ok'):
            print(f"[books]  Garden uploaded: {result['active']} active, "
                  f"{result['upcoming']} upcoming  [{ts}]")
            if result.get('skipped'):
                print(f"[books]  Skipped crops: {result['skipped']}", file=sys.stderr)
            return True
        print(f"[books]  Garden upload failed: {result}", file=sys.stderr)
        return False


# ==============================================================================
#  PART 6 | COMMAND LINE
#
#  What runs when you type `python3 gorgon_monitor.py`. Everything has a sensible
#  default, so the options exist only for the cases where the guess is wrong.
# ==============================================================================

if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser(
        description=f'Gorgon Monitor {__version__}. Leave it running while you play. '
                    'It reads the logs Project Gorgon already writes on your PC and '
                    'reports public world data (weather, garden, warden alerts, Aurest, '
                    "Agrashab, blood season, casino tips and fights, arena results, mob "
                    'drops, death XP) to the Gorgonzola community site. It sends nothing '
                    'private and never touches the game. Full details, including exactly '
                    'what is and is not sent, are in the header of this file, and at '
                    'https://gorgonzola.spellboundtavern.com/monitor.html',
        epilog='Press Ctrl-C to stop.')
    parser.add_argument('--server', default=DEFAULT_SERVER,
                        help=f'Site base URL to report to (default: {DEFAULT_SERVER})')
    parser.add_argument('--game-server', default=None, dest='game_server',
                        metavar='NAME', help='Project Gorgon world (default: auto-detect '
                                             f'from Player.log, else {FALLBACK_GAME_SERVER})')
    parser.add_argument('--data-dir', default=None, dest='data_dir', metavar='DIR',
                        help='Directory for local research files (default: script directory)')
    parser.add_argument('--save-books', default=None, dest='save_books', metavar='LIST',
                        help='Books to auto-save to the Books folder when read in game, '
                             'skipping the in-game save click: "off", "all", or a '
                             'comma-separated list of title substrings '
                             f'(default: {SAVE_BOOKS_DEFAULT}, every book you open; '
                             'env GORGON_SAVE_BOOKS)')
    parser.add_argument('--game-dir', default=None, dest='game_dir', metavar='DIR',
                        help="Project Gorgon's folder, the one holding ChatLogs and "
                             'Books (default: found automatically; env GORGON_GAME_DIR)')
    parser.add_argument('--version', action='version',
                        version=f'Gorgon Monitor {__version__}')
    args = parser.parse_args()

    # Windows writes console output in the local code page, so the arrows and ellipses
    # in the lines below would end the run the moment somebody redirected output to a
    # file. Ask for utf-8 and, failing that, for unprintable characters to be replaced
    # rather than raised. Guarded: some environments hand over a stream that cannot be
    # reconfigured, and losing an accent is not worth losing the program over.
    for stream in (sys.stdout, sys.stderr):
        try:
            stream.reconfigure(encoding='utf-8', errors='replace')
        except Exception:
            pass

    if args.game_dir:
        use_game_dir(args.game_dir)
    if not LOG_FOLDER.exists() and not PLAYER_LOG.exists():
        # Nothing to read. Say so and stop: running on regardless would look healthy
        # and report nothing, which is the one failure nobody can diagnose.
        print(game_dir_help(args.game_dir or os.environ.get('GORGON_GAME_DIR')),
              file=sys.stderr)
        sys.exit(1)

    monitor = ChatMonitor(server_url=args.server, game_server=args.game_server,
                          data_dir=args.data_dir, save_books=args.save_books)
    try:
        monitor.monitor_chat()
    except KeyboardInterrupt:
        # Ctrl-C is how the header tells people to stop it, so it is a normal exit and
        # must not look like a crash.
        monitor._flush_loot(force=True)      # don't drop drops already seen
        print(f"\n\nStopped. {REPORTS_MADE} report(s) accepted this session. "
              f"Thanks for keeping the site current.")
        sys.exit(0)

Questions

Is this allowed?

It reads log files that the game writes and that anyone can open in Notepad. It does not modify, inject into, automate or communicate with the game. Reading your own logs is the same thing every combat parser and every "what did I loot" spreadsheet does.

Can I see what it sent?

Yes. It prints every single report to its window as it makes it. Nothing is sent silently.

Do I have to run all of it?

The reports are not individually switchable yet. If you want one of them off, say so on the feedback form and it will get a switch. Book saving already has one (--save-books off).

I write bots. Can I report this data myself?

Yes, and the endpoints this uses are documented for you on the API page, with the live list of valid values.

Where does it come from?

It is the shareable twin of the monitor that has been feeding this site since it started. It lives in Gorgonzola's source as gorgon_monitor.py, the same file you download. It is standard library only and deliberately one file, so that it stays something a person can read in one sitting.