#!/usr/bin/env python3
"""
AgentCraft reference client.

Default AGENT_PROVIDER=random is a no-model starter. Keep this process running
to play. Optional OpenAI/Anthropic keys are billed by those providers, not the game.

Usage:
    python3 run_agent.py
    # or:
    export AGENTCRAFT_API_URL=http://localhost:8080
    export AGENTCRAFT_API_KEY=ac_your_key_here
    python3 run_agent.py
"""

import json
import gzip
import http.client
import io
import math
import os
import sys
import time
from typing import Any

import urllib.error
import urllib.parse
import getpass
from datetime import datetime, timezone

API_URL = os.environ.get("AGENTCRAFT_API_URL", "http://localhost:8080").rstrip("/")
API_KEY = os.environ.get("AGENTCRAFT_API_KEY", "")
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY", "")
AGENT_NAME = os.environ.get("AGENT_NAME", "agent")
PROVIDER = os.environ.get("AGENT_PROVIDER", "random")  # openai | anthropic | random
USER_AGENT = "AgentCraft-Starter/1.0 (+https://github.com/World-of-AgentCraft/world-of-agents)"

SYSTEM_PROMPT = """You are {agent_name}, a {agent_class} in AgentCraft — a model-agnostic BYOA MMO.

Class identity: {class_blurb}
AI behavior hints (not commands): {ai_hints}

Each turn you receive a JSON snapshot (observe). Reply with exactly ONE JSON action object. No markdown.

You may call a model every decision, rarely, or never — the server only allows one game action per tick regardless of model spend.

Goals (priority order):
1. Stay alive — if HP is below your class retreat threshold, REST/EAT/heal
2. If observe.invites is non-empty, PARTY_ACCEPT (or PARTY_DECLINE with a reason) — do not ignore invites
3. If in a party, consider party HP, threat, and composition before selfish DPS; heal lowest ally; tank taunt if not on threat
4. Use ABILITY when it is ready and appropriate (heals, taunts, executes, burst)
5. If inside a dungeon, ATTACK remaining mobs or DUNGEON_LEAVE if dying
6. Cooperate via PARTY_* when nearby agents exist and dungeons.available has group_recommended / group_required — create/invite, wait at the entrance, enter together
7. Follow party.objective (MOVE / DUNGEON_ENTER)
8. Accept/complete quests
9. Equip upgrades using structured stats (stats, armor_type, weapon_type, item_level) — do NOT assume highest item_level is always best
10. Economy: AUCTION_LIST vendor junk or rare+ duplicates (use auction.listings[].listing_id and action_hints). If gold is high and a listing is an upgrade, AUCTION_BID / AUCTION_BUYOUT. Gather/craft/vendor otherwise
11. Fight mobs; prefer elites/bosses when they are actually up. nearby.mobs is a slice of the zone pack — mobs_alive may be higher; elites/rares are uncommon/rare spawns, not always present

Rules:
- Output ONLY one JSON object like: {{"action": "ATTACK", "target": "mob_id"}}
- Only use IDs from observe (listing_id, item_id, ally names/ids, dungeon_id)
- Use action_hints for PARTY_* and AUCTION_* param names — names-only available_actions is not enough
- One action per tick
- MOVE only to available_zones
- Priest/Paladin can heal allies with ABILITY target ally_id
- nearby.agents[].say is public speech from this tick; party.messages is party chat
- Allowed actions include ABILITY, SAY, PARTY_CREATE/INVITE/ACCEPT/DECLINE/LEAVE/SAY, AUCTION_LIST/BID/BUYOUT/CANCEL

Static catalogs (recipes/achievements/items/zones) are at catalogs.endpoints — do not expect them in every observe."""


def class_prompt_bits(obs: dict[str, Any]) -> tuple[str, str, str]:
    agent = obs.get("agent", {})
    cls = agent.get("class_name") or agent.get("class") or "Adventurer"
    passive = agent.get("class_passive") or {}
    hints = agent.get("ai_behavior_hints") or {}
    blurb = f"{cls}. Passive: {passive.get('name', 'None')} — {passive.get('description', '')}"
    return cls, blurb, json.dumps(hints)


_http_connection: http.client.HTTPConnection | None = None
_http_origin: tuple[str, str, int] | None = None


def _connection_for(url: str) -> tuple[http.client.HTTPConnection, str]:
    global _http_connection, _http_origin
    parsed = urllib.parse.urlsplit(url)
    scheme = parsed.scheme.lower()
    port = parsed.port or (443 if scheme == "https" else 80)
    origin = (scheme, parsed.hostname or "", port)
    if _http_connection is None or _http_origin != origin:
        if _http_connection is not None:
            _http_connection.close()
        connection_type = http.client.HTTPSConnection if scheme == "https" else http.client.HTTPConnection
        _http_connection = connection_type(origin[1], port, timeout=15)
        _http_origin = origin
    target = parsed.path or "/"
    if parsed.query:
        target += "?" + parsed.query
    return _http_connection, target


def request_json(path: str, body: dict[str, Any] | None = None, params: dict | None = None) -> dict:
    global _http_connection
    url = f"{API_URL}/api/v1/{path}"
    if params:
        url += "?" + urllib.parse.urlencode(params)
    encoded = json.dumps(body).encode() if body is not None else None
    method = "POST" if body is not None else "GET"
    for attempt in range(2):
        try:
            connection, target = _connection_for(url)
            connection.request(method, target, body=encoded, headers=headers())
            response = connection.getresponse()
            raw = response.read()
            if response.headers.get("Content-Encoding") == "gzip":
                raw = gzip.decompress(raw)
            if response.status >= 400:
                raise urllib.error.HTTPError(
                    url, response.status, response.reason, response.headers, io.BytesIO(raw)
                )
            return json.loads(raw)
        except urllib.error.HTTPError:
            raise
        except (http.client.HTTPException, OSError) as error:
            if _http_connection is not None:
                _http_connection.close()
            _http_connection = None
            if attempt:
                raise urllib.error.URLError(error) from error
    raise urllib.error.URLError("request failed")


def headers() -> dict[str, str]:
    return {
        "Authorization": f"Bearer {API_KEY}",
        "Content-Type": "application/json",
        "Accept-Encoding": "gzip",
        "User-Agent": USER_AGENT,
    }


_last_obs_by_key: dict[str, dict[str, Any]] = {}


def _obs_cache_key() -> str:
    return f"{API_URL}|{API_KEY}"


def _merge_delta(full: dict[str, Any], delta: dict[str, Any]) -> dict[str, Any]:
    merged = dict(full)
    for key, value in delta.items():
        if key not in ("agent", "nearby"):
            merged[key] = value
    if isinstance(delta.get("nearby"), dict):
        nearby = dict(merged.get("nearby") or {})
        nearby.update(delta["nearby"])
        merged["nearby"] = nearby
    if isinstance(delta.get("agent"), dict) and isinstance(merged.get("agent"), dict):
        agent = dict(merged["agent"])
        agent.update(delta["agent"])
        merged["agent"] = agent
    return merged


def observe(**kwargs) -> dict[str, Any]:
    key = _obs_cache_key()
    last = _last_obs_by_key.get(key)
    mode = kwargs.get("mode") or os.environ.get("OBSERVE_MODE", "delta")
    params: dict[str, Any] = {"mode": mode}
    if mode == "delta" and last is not None and last.get("revision") is not None:
        params["since"] = str(last["revision"])
    payload = request_json("observe", params=params)
    validate_observation_sections(payload)
    if payload.get("mode") == "delta" and last is not None and payload["revision"] < last["revision"]:
        payload = request_json("observe", params={"mode": "full"})
        validate_observation_sections(payload)
    if payload.get("mode") == "delta" and last is not None:
        merged = _merge_delta(last, payload)
        validate_observation(merged)
        _last_obs_by_key[key] = merged
        return merged
    if payload.get("mode") == "delta" and last is None:
        payload = request_json("observe", params={"mode": "full"})
    validate_observation(payload)
    _last_obs_by_key[key] = payload
    return payload


def validate_observation_sections(obs: Any) -> None:
    """Reject malformed deltas before they can hide behind a cached snapshot."""
    valid = isinstance(obs, dict) and all(
        type(obs.get(key)) is int for key in ("tick", "revision")
    )
    if valid:
        for key, expected in (("agent", dict), ("nearby", dict), ("quests", dict),
                              ("inventory", list), ("abilities", list), ("travel", list)):
            if key in obs and not isinstance(obs[key], expected):
                valid = False
    if not valid:
        _last_obs_by_key.pop(_obs_cache_key(), None)
        raise ValueError("Incomplete world update; requesting a fresh snapshot")


def validate_observation(obs: Any) -> None:
    validate_observation_sections(obs)
    agent = obs.get("agent")
    if not isinstance(agent, dict) or not all(
        type(agent.get(k)) in (int, float) and math.isfinite(agent[k]) for k in ("hp", "max_hp", "level")
    ) or agent["max_hp"] <= 0 or agent["level"] < 1 or not isinstance(obs.get("nearby"), dict):
        _last_obs_by_key.pop(_obs_cache_key(), None)
        raise ValueError("Incomplete world update; requesting a fresh snapshot")


def tick_delay(obs: dict[str, Any]) -> float:
    try:
        ends = datetime.fromisoformat(str(obs.get("tick_ends_at", "")).replace("Z", "+00:00"))
        return max(0.25, min(2.0, (ends - datetime.now(timezone.utc)).total_seconds() + 0.1))
    except (ValueError, TypeError):
        return 2.0


def retry_delay(value: str | None, failures: int) -> float:
    fallback = min(60, 2 ** min(failures, 6))
    try:
        return max(fallback, min(60, float(value or "2")))
    except ValueError:
        return fallback


def route_to(obs: dict[str, Any], destination: str) -> dict[str, Any] | None:
    """Shortest connected route, using only travel data supplied by the world."""
    origin = obs.get("agent", {}).get("zone")
    graph = {z["id"]: z.get("connected", []) for z in obs.get("travel") or []}
    queue = [(origin, [])]
    visited = {origin}
    for zone, path in queue:
        if zone == destination and path:
            return {"action": "MOVE", "zone": path[0]}
        for adjacent in graph.get(zone, obs.get("available_zones", []) if zone == origin else []):
            if adjacent not in visited:
                visited.add(adjacent)
                queue.append((adjacent, [*path, adjacent]))
    return None


def travel_action(obs: dict[str, Any]) -> dict[str, Any]:
    agent = obs.get("agent") or {}
    connected = obs.get("available_zones") or (obs.get("zone") or {}).get("connected") or []
    options = [z for z in obs.get("travel") or [] if z["id"] in connected and not z.get("safe") and z.get("level_min", 1) <= agent.get("level", 1)]
    if options:
        # Prefer a suitable level band, then an unexplored zone. No random invalid moves.
        destination = min(options, key=lambda z: (agent.get("level", 1) > z.get("level_max", 100), z["id"] in agent.get("zones_visited", []), -z.get("level_min", 1), z["id"]))
        return {"action": "MOVE", "zone": destination["id"]}
    if connected:
        return {"action": "MOVE", "zone": connected[0]}
    return {"action": "REST"}


def decision_reason(obs: dict[str, Any], action: dict[str, Any]) -> str:
    if action["action"] == "REST":
        if obs.get("agent", {}).get("hp", 0) < obs.get("agent", {}).get("max_hp", 1):
            return "Recovering health before the next fight"
        return "No reachable destination or ready target; checking again next tick"
    if action["action"] == "MOVE":
        return f"Travelling to {action['zone']}"
    return f"{action['action'].replace('_', ' ').capitalize()}" + (f" ({action['target']})" if action.get("target") else "")


def act(action: dict[str, Any]) -> dict[str, Any]:
    return request_json("act", body=action)


def parse_action(text: str) -> dict[str, Any]:
    text = text.strip()
    try:
        obj = json.loads(text)
        if isinstance(obj, dict):
            return obj
    except json.JSONDecodeError:
        pass
    start = text.find("{")
    if start < 0:
        raise ValueError("no JSON object in model output")
    depth = 0
    in_str = False
    escape = False
    for i in range(start, len(text)):
        ch = text[i]
        if in_str:
            if escape:
                escape = False
            elif ch == "\\":
                escape = True
            elif ch == '"':
                in_str = False
            continue
        if ch == '"':
            in_str = True
        elif ch == "{":
            depth += 1
        elif ch == "}":
            depth -= 1
            if depth == 0:
                return json.loads(text[start : i + 1])
    raise ValueError("unbalanced JSON object in model output")

def decide_openai(obs: dict[str, Any]) -> dict[str, Any]:
    from openai import OpenAI

    client = OpenAI(api_key=OPENAI_API_KEY)
    agent_name = obs.get("agent", {}).get("name", AGENT_NAME)
    cls, blurb, hints = class_prompt_bits(obs)
    response = client.chat.completions.create(
        model=os.environ.get("OPENAI_MODEL", "gpt-4o-mini"),
        messages=[
            {
                "role": "system",
                "content": SYSTEM_PROMPT.format(
                    agent_name=agent_name, agent_class=cls, class_blurb=blurb, ai_hints=hints
                ),
            },
            {"role": "user", "content": json.dumps(obs)},
        ],
        temperature=0.3,
        max_tokens=250,
    )
    return parse_action(response.choices[0].message.content or "{}")


def decide_anthropic(obs: dict[str, Any]) -> dict[str, Any]:
    import anthropic

    client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)
    agent_name = obs.get("agent", {}).get("name", AGENT_NAME)
    cls, blurb, hints = class_prompt_bits(obs)
    response = client.messages.create(
        model=os.environ.get("ANTHROPIC_MODEL", "claude-3-5-haiku-latest"),
        max_tokens=250,
        system=SYSTEM_PROMPT.format(
            agent_name=agent_name, agent_class=cls, class_blurb=blurb, ai_hints=hints
        ),
        messages=[{"role": "user", "content": json.dumps(obs)}],
    )
    text = response.content[0].text if response.content else "{}"
    return parse_action(text)


RARITY_RANK = {"common": 0, "uncommon": 1, "rare": 2, "epic": 3, "legendary": 4}


def _item_score(it: dict[str, Any] | None) -> float:
    if not it:
        return -1
    st = it.get("stats") or {}
    stat_sum = sum(st.values()) if isinstance(st, dict) else 0
    return (it.get("weapon_damage") or it.get("attack") or 0) + (it.get("defense") or 0) + stat_sum


EQUIPMENT_SLOTS = {
    "main_hand",
    "chest",
    "head",
    "off_hand",
    "two_hand",
    "necklace",
    "ring",
    "trinket",
    "shoulders",
    "hands",
    "legs",
    "feet",
}


def _eligible_for_agent(item: dict[str, Any], agent: dict[str, Any]) -> bool:
    return (
        (item.get("required_level") or 1) <= agent.get("level", 1)
        and (not item.get("armor_type") or item["armor_type"] in agent.get("armor_proficiencies", []))
        and (not item.get("weapon_type") or item["weapon_type"] in agent.get("weapon_proficiencies", []))
    )


def _equip_upgrade(obs: dict[str, Any], inventory: list[dict[str, Any]]) -> dict[str, Any] | None:
    """Return one safe upgrade action, including the two jewelry slots."""
    agent = obs.get("agent") or {}
    equipped = obs.get("equipped") or {}
    for item in inventory:
        slot = item.get("slot")
        if slot not in EQUIPMENT_SLOTS or not _eligible_for_agent(item, agent):
            continue

        if slot in ("ring", "trinket"):
            choices = (slot, f"{slot}2")
            eq_slot = min(choices, key=lambda name: _item_score(equipped.get(name)))
            current_score = _item_score(equipped.get(eq_slot))
        elif slot == "two_hand":
            eq_slot = "main_hand"
            current_score = _item_score(equipped.get("main_hand"))
            if (equipped.get("main_hand") or {}).get("slot") != "two_hand":
                current_score += max(0, _item_score(equipped.get("off_hand")))
        else:
            eq_slot = slot
            # Equipping an off hand removes a two-hander and would leave the agent
            # weaponless. Wait until a one-handed upgrade has been equipped first.
            if slot == "off_hand" and (equipped.get("main_hand") or {}).get("slot") == "two_hand":
                continue
            current_score = _item_score(equipped.get(eq_slot))

        if _item_score(item) > current_score:
            return {"action": "EQUIP", "item_id": item["id"], "slot": eq_slot}
    return None


def _role(obs: dict[str, Any]) -> str:
    agent = obs.get("agent") or {}
    hints = agent.get("ai_behavior_hints") or {}
    return str(hints.get("partyRoleHint") or "")


def _group_dungeons(obs: dict[str, Any]) -> list[dict[str, Any]]:
    available = ((obs.get("dungeons") or {}).get("available") or [])
    return [d for d in available if d.get("difficulty_type") in ("group_recommended", "group_required")]


def _party_members_here(obs: dict[str, Any]) -> int:
    zone = (obs.get("agent") or {}).get("zone")
    party = obs.get("party") or {}
    members = party.get("members") or []
    zoned = [m for m in members if m.get("zone") is not None]
    if zoned:
        return sum(1 for m in zoned if m.get("zone") == zone)
    return len(members)


def decide_random(obs: dict[str, Any]) -> dict[str, Any]:
    """Fallback: simple rule-based agent for testing without an LLM."""
    import random

    agent = obs.get("agent", {})
    hp_pct = agent.get("hp", 0) / max(agent.get("max_hp", 100), 1)
    party = obs.get("party") or {}
    members = party.get("members") or []
    invites = obs.get("invites") or []
    nearby = obs.get("nearby", {})
    dungeon = (obs.get("dungeons") or {}).get("current")
    mobs = nearby.get("mobs") or []
    inv = obs.get("inventory") or []
    gold = agent.get("gold") or 0
    group_ds = _group_dungeons(obs)
    retreat_threshold = float((agent.get("ai_behavior_hints") or {}).get("retreatThresholdHp") or 0.3)

    if hp_pct < retreat_threshold:
        healing_items = [item for item in inv if (item.get("heal_amount") or 0) > 0]
        if healing_items:
            missing_hp = max(agent.get("max_hp", 100) - agent.get("hp", 0), 0)
            enough = [item for item in healing_items if item.get("heal_amount", 0) >= missing_hp]
            chosen = min(enough, key=lambda item: item["heal_amount"]) if enough else max(
                healing_items, key=lambda item: item["heal_amount"]
            )
            return {"action": "EAT", "item_id": chosen["id"]}
        return {"action": "REST"}

    # Incoming invites are visible on observe — never ignore them
    if invites:
        chosen = invites[0]
        pid = chosen.get("party_id")
        extra = {"party_id": pid} if pid else {}
        if party:
            return {"action": "PARTY_DECLINE", **extra}
        return {"action": "PARTY_ACCEPT", **extra}

    if members:
        lowest = min(members, key=lambda m: m.get("hp_pct", 1))
        if lowest.get("hp_pct", 1) < 0.55:
            for ab in obs.get("abilities") or []:
                if ab.get("ready") and ab.get("id") in ("heal", "renew", "lay_on_hands", "aegis_of_dawn"):
                    return {"action": "ABILITY", "ability": ab["id"], "ally_id": lowest.get("id")}

    # Tank: taunt if boss is not on us
    if dungeon and mobs:
        boss = next((m for m in mobs if m.get("boss")), None)
        threat = (boss or {}).get("threat") or {}
        if threat.get("current_target") and threat.get("current_target") != agent.get("id"):
            for ab in obs.get("abilities") or []:
                if ab.get("ready") and ab.get("id") == "taunt" and boss:
                    return {"action": "ABILITY", "ability": "taunt", "target": boss["id"]}

    # Inside dungeon: fight or bail
    if dungeon:
        if hp_pct < 0.25:
            return {"action": "DUNGEON_LEAVE"}
        if mobs:
            priority = [m for m in mobs if m.get("boss")] or [m for m in mobs if m.get("elite")] or mobs
            return {"action": "ATTACK", "target": random.choice(priority)["id"]}
        return {"action": "DUNGEON_LEAVE"}

    # Group content is optional. Offline allies or an unfinished objective must
    # never prevent the default starter from playing solo in the overworld.
    for d in group_ds:
        needed = max(int(d.get("min_party_size") or 2), 2)
        if party and agent.get("level", 1) >= d.get("min_level", 99) and hp_pct > 0.7 and _party_members_here(obs) >= needed:
            return {"action": "DUNGEON_ENTER", "dungeon_id": d["id"]}

    # Gear changes should not be starved by endless combat or gathering. Equipment
    # still has to meet level and class proficiency requirements.
    upgrade = _equip_upgrade(obs, inv)
    if upgrade:
        return upgrade

    # Turn in complete quests
    for q in obs.get("quests", {}).get("active", []):
        if q.get("complete"):
            destination = q.get("turn_in_zone")
            if not destination or destination == agent.get("zone"):
                return {"action": "QUEST_TURN_IN", "quest_id": q["id"]}
            route = route_to(obs, destination)
            if route:
                return route

    # Accept available quests
    available = obs.get("quests", {}).get("available", [])
    active_quests = obs.get("quests", {}).get("active", [])
    # The server caps active quests at five. Once full, keep making progress
    # instead of repeatedly submitting rejected QUEST_ACCEPT actions.
    if available and len(active_quests) < 5:
        return {"action": "QUEST_ACCEPT", "quest_id": available[0]["id"]}

    # Priest self-heal / class ability when ready
    for ab in obs.get("abilities") or []:
        if ab.get("ready") and ab.get("id") in ("heal", "renew", "slam", "fireball", "shadow_bolt", "aimed_shot"):
            if ab["id"] in ("heal", "renew") and hp_pct < 0.7:
                return {"action": "ABILITY", "ability": ab["id"], "ally_id": agent.get("id")}
            if mobs and ab["id"] not in ("heal", "renew"):
                return {"action": "ABILITY", "ability": ab["id"], "target": mobs[0]["id"]}

    # Auction: list junk / rare+ duplicates; bid/buyout upgrades
    auction = obs.get("auction") or {}
    listings = auction.get("listings") or []
    equipped = obs.get("equipped") or {}

    def _is_upgrade_listing(listing: dict[str, Any]) -> bool:
        slot = listing.get("slot")
        if slot not in EQUIPMENT_SLOTS or not listing.get("stats") or not _eligible_for_agent(listing, agent):
            return False
        eq_slot = "main_hand" if slot == "two_hand" else slot
        if slot == "ring":
            eq_slot = "ring" if not equipped.get("ring") else "ring2"
        if slot == "trinket":
            eq_slot = "trinket" if not equipped.get("trinket") else "trinket2"
        cur = equipped.get(eq_slot) or {}
        return _item_score(listing) > _item_score(cur)

    if gold > 150:
        for listing in listings:
            if listing.get("yours") or listing.get("your_bid"):
                continue
            buyout = listing.get("buyout")
            if buyout and gold >= buyout and _is_upgrade_listing(listing):
                return {"action": "AUCTION_BUYOUT", "listing_id": listing["listing_id"]}
            min_bid = (listing.get("current_bid") or 0) + 1 if listing.get("current_bid") else listing.get("start_bid") or 1
            if gold >= min_bid * 2 and _is_upgrade_listing(listing):
                return {"action": "AUCTION_BID", "listing_id": listing["listing_id"], "amount": int(min_bid)}

    names: dict[str, list[dict[str, Any]]] = {}
    for item in inv:
        names.setdefault(item.get("name") or "", []).append(item)
    rare_dupe = next(
        (
            copies[1]
            for copies in names.values()
            if len(copies) > 1 and RARITY_RANK.get(str(copies[0].get("rarity") or ""), 0) >= 2
        ),
        None,
    )
    junk = [i for i in inv if i.get("rarity") == "common" and i.get("slot") in ("misc", "material")]
    if (rare_dupe or (len(inv) >= 16 and junk)) and auction.get("your_active_listings", 0) < auction.get("max_active_listings", 10):
        listed = rare_dupe or junk[0]
        start = 20 if RARITY_RANK.get(str(listed.get("rarity") or ""), 0) >= 2 else 5
        return {
            "action": "AUCTION_LIST",
            "item_id": listed["id"],
            "start_bid": start,
            "buyout": start * 4,
        }

    # Clean bags while a vendor is present. This runs before routine combat and
    # gathering so a permanently populated zone cannot starve inventory upkeep.
    vendors = obs.get("vendors") or []
    if vendors and len(inv) > 12:
        vendor_junk = [i for i in inv if i.get("rarity") == "common" and i.get("slot") == "misc"]
        obsolete_gear = [
            i
            for i in inv
            if i.get("rarity") == "common"
            and i.get("slot") in EQUIPMENT_SLOTS
            and (i.get("required_level") or 1) <= agent.get("level", 1)
            and _equip_upgrade(obs, [i]) is None
        ]
        if vendor_junk or obsolete_gear:
            item = (vendor_junk or obsolete_gear)[0]
            return {"action": "VENDOR_SELL", "vendor_id": vendors[0]["id"], "item_id": item["id"]}

    zone = obs.get("zone") or {}
    if zone.get("safe") or (agent.get("level", 1) > zone.get("level_max", 100) and obs.get("available_zones")):
        return travel_action(obs)

    # Group entry was checked above using the actual number of nearby members.
    # A party record alone does not make the group ready.
    for d in (obs.get("dungeons") or {}).get("available") or []:
        if d.get("difficulty_type") in ("group_required", "group_recommended"):
            continue
        if agent.get("level", 1) >= d.get("min_level", 99) and hp_pct > 0.7:
            return {"action": "DUNGEON_ENTER", "dungeon_id": d["id"]}

    if nearby.get("chests"):
        return {"action": "LOOT", "target": random.choice(nearby["chests"])["id"]}

    # Gather quest objectives before respawning mobs can starve them forever.
    professions = obs.get("professions") or {}
    wanted = {o.get("target") for q in obs.get("quests", {}).get("active", []) if not q.get("complete") for o in q.get("objectives") or [] if o.get("type") == "gather" and q.get("progress", {}).get("gather:" + o.get("target", ""), 0) < o.get("count", 1)}
    nodes = [n for n in nearby.get("nodes") or [] if professions.get(n.get("profession"), {}).get("level", 0) >= n.get("min_level", 1) and n.get("type") in wanted]
    if nodes:
        return {"action": "GATHER", "target": nodes[0]["id"]}

    # Prefer bosses/elites when healthy
    if mobs and hp_pct > 0.6:
        priority = [m for m in mobs if m.get("boss")] or [m for m in mobs if m.get("elite")] or mobs
        return {"action": "ATTACK", "target": random.choice(priority)["id"]}
    if mobs:
        return {"action": "ATTACK", "target": random.choice(mobs)["id"]}

    # Craft if we can
    craftable = [r for r in obs.get("recipes") or [] if r.get("can_craft")]
    if craftable and random.random() < 0.25:
        return {"action": "CRAFT", "recipe": random.choice(craftable)["id"]}

    return travel_action(obs)


def decide(obs: dict[str, Any]) -> dict[str, Any]:
    if PROVIDER == "openai" and OPENAI_API_KEY:
        return decide_openai(obs)
    if PROVIDER == "anthropic" and ANTHROPIC_API_KEY:
        return decide_anthropic(obs)
    return decide_random(obs)


def main() -> None:
    global API_URL, API_KEY
    if not os.environ.get("AGENTCRAFT_API_URL") and sys.stdin.isatty():
        API_URL = (input("Paste the game address from your dashboard: ").strip() or API_URL).rstrip("/")
    if not API_KEY and sys.stdin.isatty():
        API_KEY = getpass.getpass("Paste your champion key (hidden): ").strip()
    from urllib.parse import urlparse
    parsed = urlparse(API_URL)
    if parsed.scheme != "https" and not (parsed.scheme == "http" and parsed.hostname in ("localhost", "127.0.0.1")):
        print("Use an HTTPS game address (HTTP is allowed only for local testing).", file=sys.stderr)
        return
    if not API_KEY:
        print("Missing champion key. Run interactively or set AGENTCRAFT_API_KEY.", file=sys.stderr)
        return
    if PROVIDER not in ("random", "openai", "anthropic") or (PROVIDER == "openai" and not OPENAI_API_KEY) or (PROVIDER == "anthropic" and not ANTHROPIC_API_KEY):
        print("Provider/key mismatch. Use AGENT_PROVIDER=random for the free starter.", file=sys.stderr)
        return
    print(f"Connecting to {API_URL}. Mode: {PROVIDER}")
    print("Keep this program running to play. Ctrl+C pauses your champion. Progress stays saved.")
    last_tick = None
    failures = 0
    steps = 0
    max_steps = int(os.environ.get("AGENT_MAX_STEPS", "0"))
    while not max_steps or steps < max_steps:
        delay = 2.0
        try:
            obs = observe()
            validate_observation(obs)
            tick = obs["tick"]
            if tick == last_tick:
                time.sleep(0.5)
                continue
            agent = obs.get("agent", {})
            print(f"[tick {tick}] {agent.get('name')} L{agent.get('level')} HP:{agent.get('hp')}/{agent.get('max_hp')} Gold:{agent.get('gold')} Zone:{agent.get('zone')}")
            if obs.get("last_action_result"):
                print(f"  Last result: {obs['last_action_result']}")
            action = decide(obs)
            print(f"  Plan: {decision_reason(obs, action)}")
            response = act(action)
            if not isinstance(response, dict) or not response.get("accepted"):
                raise ValueError("Server did not confirm the action")
            last_tick = tick
            steps += 1
            failures = 0
            print(f"  Queued {action['action']}; waiting for the next tick.")
            delay = tick_delay(obs)
        except urllib.error.HTTPError as error:
            failures += 1
            if error.code == 401:
                print("Champion key rejected. Copy a new key from your dashboard and restart.", file=sys.stderr)
                return
            if error.code == 409:
                last_tick = tick if "tick" in locals() else None
                delay = tick_delay(obs) if "obs" in locals() else 2
                print("  This tick already has an action; waiting for the next world update.")
            else:
                delay = min(60, 2 ** min(failures, 6))
                if error.code in (429, 503):
                    delay = retry_delay(error.headers.get("Retry-After") if error.headers else None, failures)
                detail = error.read().decode(errors="replace")
                try:
                    detail = json.loads(detail).get("error", "Request rejected")
                except (ValueError, AttributeError):
                    detail = "Request rejected"
                print(f"Server returned {error.code}: {detail}. Retrying in {delay:g}s.", file=sys.stderr)
        except (urllib.error.URLError, TimeoutError, ValueError, KeyError) as error:
            failures += 1
            delay = min(60, 2 ** min(failures, 6))
            print(f"Connection/action problem: {error}. Retrying in {delay:g}s.", file=sys.stderr)
        except KeyboardInterrupt:
            print("Champion paused. Run this program again to reconnect.")
            return
        time.sleep(delay)


if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        print("Champion paused.")
