#!/usr/bin/env python3 """PokerBotsArena no-money test client. Every hand starts each agent at 100 nonredeemable test chips. `auto` plays nonstop: it stays queued and live, and keeps answering turns until you stop it (Ctrl-C) or an optional --max-hands / --max-hours limit that you set yourself is reached.""" import argparse import json import subprocess import sys import threading import time import urllib.error import urllib.request from pathlib import Path KEY = "AIzaSyAQZxuNipqAuNuFDW9uf6qaf7GmRhA4Vl0" # Public Firebase web-app key. ROOT = "https://firestore.googleapis.com/v1/projects/pokerbot-ai/databases/(default)/documents" TOKEN_URL = f"https://securetoken.googleapis.com/v1/token?key={KEY}" TRANSIENT = {429, 500, 502, 503, 504} HEARTBEAT_SECONDS = 15 # The runner treats a client as offline after 60s without one. TURN_SECONDS = 120 def value(raw): if "stringValue" in raw: return raw["stringValue"] if "integerValue" in raw: return int(raw["integerValue"]) if "booleanValue" in raw: return raw["booleanValue"] if "nullValue" in raw: return None if "arrayValue" in raw: return [value(item) for item in raw["arrayValue"].get("values", [])] if "mapValue" in raw: return {key: value(item) for key, item in raw["mapValue"].get("fields", {}).items()} raise ValueError("Unknown Firestore field") def doc(raw): return {key: value(item) for key, item in raw.get("fields", {}).items()} class HttpError(RuntimeError): def __init__(self, code, detail): super().__init__(f"Request failed ({code}): {detail}") self.code = code def request(url, method="GET", payload=None, token=None): headers = {"Content-Type": "application/json"} if token: headers["Authorization"] = "Bearer " + token body = json.dumps(payload).encode() if payload is not None else None item = urllib.request.Request(url, data=body, method=method, headers=headers) try: with urllib.request.urlopen(item, timeout=20) as response: return json.load(response) except urllib.error.HTTPError as error: if error.code == 404 and method == "GET": return None try: detail = json.load(error).get("error", {}).get("message", str(error)) except (ValueError, AttributeError): detail = str(error) raise HttpError(error.code, detail) from None def fields(values): result = {} for key, item in values.items(): if isinstance(item, str): result[key] = {"stringValue": item} elif isinstance(item, int): result[key] = {"integerValue": str(item)} else: raise TypeError(key) return {"fields": result} class Session: """Firebase ID token shared by the main loop and the heartbeat thread.""" def __init__(self, refresh_token): self.refresh_token = refresh_token self.lock = threading.Lock() self.token, self.expires_at = None, 0 def bearer(self, force=False): with self.lock: if force or time.time() >= self.expires_at: auth = request(TOKEN_URL, "POST", {"grant_type": "refresh_token", "refresh_token": self.refresh_token}) self.token = auth["id_token"] self.expires_at = time.time() + int(auth.get("expires_in", 3600)) - 90 return self.token def call(self, url, method="GET", payload=None): try: return request(url, method, payload, self.bearer()) except HttpError as error: if error.code != 401: raise return request(url, method, payload, self.bearer(force=True)) def get(self, path): raw = self.call(ROOT + "/" + path) return doc(raw) if raw else None def transient(error): return isinstance(error, (OSError, ValueError)) or (isinstance(error, HttpError) and error.code in TRANSIENT) def note(**event): print(json.dumps(event), file=sys.stderr, flush=True) def with_retry(action, label, give_up_at, sleep=time.sleep): """Retry quota (429), 5xx and network failures with exponential backoff.""" delay = 2 while True: try: return action() except (HttpError, OSError, ValueError) as error: if not transient(error) or time.time() + delay > give_up_at: raise quota = isinstance(error, HttpError) and error.code == 429 note(status="firestore_quota_backoff" if quota else "network_backoff", during=label, error=str(error)[:200], retryInSeconds=delay) sleep(delay) delay = min(delay * 2, 300) class Heartbeat(threading.Thread): """Keeps readiness fresh while the main thread waits or a strategy thinks.""" def __init__(self, session, uid, interval=HEARTBEAT_SECONDS): super().__init__(daemon=True) self.session, self.uid, self.interval = session, uid, interval self.stopped = threading.Event() def beat(self): self.session.call(ROOT + f"/testReady/{self.uid}", "PATCH", fields({"mode": "free_heads_up_v1", "lastSeen": int(time.time())})) def run(self): delay = self.interval while not self.stopped.wait(delay): try: self.beat() delay = self.interval except (HttpError, OSError, ValueError) as error: delay = min(delay * 2, 120) note(status="heartbeat_failed", error=str(error)[:200], retryInSeconds=delay) def fallback_action(legal): """Deterministic legal default: check when free, otherwise fold.""" for move in ("check", "fold", "call"): if move in legal: return {"action": move} return {"action": "fold"} def validate(decision, turn): if not isinstance(decision, dict): raise ValueError("strategy output is not a JSON object") legal, move = turn.get("legalActions", []), decision.get("action") if move not in legal: raise ValueError(f"illegal action {move!r}; legal: {legal}") payload = {"action": move} if move == "raise": amount = decision.get("raiseTo") if type(amount) is not int or not turn["minRaiseTo"] <= amount <= turn["maxRaiseTo"]: raise ValueError(f"illegal raiseTo {amount!r}; range {turn['minRaiseTo']}-{turn['maxRaiseTo']}") payload["raiseTo"] = amount return payload def choose(turn, strategy_script=None, timeout=90): """Return (legal payload, fallback reason or None). Never raises for strategy faults.""" legal = turn.get("legalActions", []) if not strategy_script: # Rule-based protocol fallback, not a connected LLM. return {"action": "check" if "check" in legal else "call" if "call" in legal else "fold"}, None try: result = subprocess.run( [sys.executable, str(Path(strategy_script).expanduser())], input=json.dumps(turn), text=True, capture_output=True, timeout=timeout, ) if result.returncode: raise ValueError(f"strategy exited {result.returncode}: {result.stderr[-300:]}") return validate(json.loads(result.stdout), turn), None except subprocess.TimeoutExpired: return fallback_action(legal), f"strategy timed out after {timeout}s" except (ValueError, OSError, KeyError, TypeError) as error: return fallback_action(legal), str(error)[:300] def submit(session, uid, turn, payload, give_up_at, sleep=time.sleep): url = ROOT + f"/testActions/{uid}/turns/{turn['turnId']}?currentDocument.exists=false" delay = 1 while True: try: session.call(url, "PATCH", fields(payload)) return "submitted" except HttpError as error: if error.code in (400, 409) and "exist" in str(error).lower(): return "submitted" # An earlier attempt landed but its reply was lost. if error.code == 403: return "rejected_turn_closed" # The runner already applied its fallback. if error.code not in TRANSIENT: return f"rejected_http_{error.code}" except (OSError, ValueError): pass if time.time() + delay > give_up_at: return "gave_up_after_deadline" sleep(delay) delay *= 2 class Poll: """Adaptive polling: fast right after a change, slower while nothing moves.""" def __init__(self): self.delay = 1.0 def next(self, changed, phase): low, high = {"my_hand": (1, 4), "between_hands": (1, 10), "idle": (2, 30)}[phase] self.delay = low if changed else min(high, max(low, self.delay * 1.5)) return self.delay def join_queue(session, uid): """Create the queue entry if it is absent. Being queued already is fine.""" try: session.call(ROOT + f"/testQueue/{uid}?currentDocument.exists=false", "PATCH", fields({"agentId": uid, "mode": "free_heads_up_v1"})) return "queued" except HttpError as error: if error.code in (400, 409) and "exist" in str(error).lower(): return "already_queued" raise def autoplay(session, uid, args, sleep=time.sleep, requeue=None): """Play nonstop: answer every turn and rejoin the queue whenever a series ends or pauses, until an optional owner-set limit (--max-hands settled hands, --max-hours). Reads only the private turn doc (one document per poll); the runner mirrors series state into it.""" requeue = requeue or (lambda: join_queue(session, uid)) seen_matches, seen_turns, ended, announced = set(), set(), set(), set() # Settled hands per series, counted from hand numbers so a missed poll never loses a hand: # progress[series] = hands settled so far, baseline[series] = those settled before we saw it. progress, baseline = {}, {} series_id, last, poll = None, None, Poll() stop_at = time.time() + args.max_hours * 3600 if args.max_hours else float("inf") limit = args.max_hands or None def rejoin(reason): try: state = with_retry(requeue, "requeue", stop_at, sleep) print(json.dumps({"queue": state, "after": reason}), flush=True) except (HttpError, OSError, ValueError) as error: note(status="requeue_failed", error=str(error)[:200]) def settled(): return sum(progress[sid] - baseline[sid] for sid in progress) def observe(turn, initial): sid, number = turn["seriesId"], turn.get("handNumber") or 0 done = max(number if turn.get("status") == "complete" else number - 1, turn.get("seriesCompletedHands") or 0, 0) if sid not in progress: # A series already in the turn doc at start counts nothing; a later one counts # from its current hand on (a resumed series keeps its hand numbers). baseline[sid] = progress[sid] = done if initial else max(0, number - 1) progress[sid] = max(progress[sid], done) print(json.dumps({"status": "ready_for_nonstop_play", "agentId": uid}), flush=True) rejoin("start") initial = True while (limit is None or settled() < limit) and time.time() < stop_at: turn = with_retry(lambda: session.get(f"testTurns/{uid}"), "read_turn", stop_at, sleep) changed, last = turn != last, turn if turn and turn.get("matchId") and "seriesId" not in turn and "legacy" not in announced: announced.add("legacy") note(status="runner_protocol_outdated", detail="turn has no seriesId; the operator must update the runner") if turn and turn.get("seriesId"): observe(turn, initial) initial = False sid, series_status = turn["seriesId"], turn.get("seriesStatus", "playing") if series_id is None and turn.get("status") == "playing" and series_status == "playing": series_id = sid if sid == series_id: if turn.get("status") == "playing" and turn.get("yourTurn") and turn.get("turnId") not in seen_turns: started = time.time() payload, reason = choose(turn, args.strategy_script, args.strategy_timeout) outcome = submit(session, uid, turn, payload, started + TURN_SECONDS, sleep) seen_turns.add(turn["turnId"]) print(json.dumps({"hand": turn.get("handNumber"), "matchId": turn["matchId"], "action": payload, "submission": outcome, **({"fallback": reason} if reason else {})}), flush=True) continue if turn.get("status") == "complete" and turn["matchId"] not in seen_matches: seen_matches.add(turn["matchId"]) print(json.dumps({"handCompleted": turn.get("handNumber"), "stacks": turn.get("stacks"), "settledHands": settled()}), flush=True) key = (sid, turn.get("seriesCompletedHands")) if series_status != "playing" and key not in ended: # Settled hands stand. Rejoin the queue so play can continue as soon as possible. ended.add(key) print(json.dumps({"seriesId": sid, "status": series_status, "completedHands": turn.get("seriesCompletedHands"), "totalHandsCompleted": settled()}), flush=True) series_id = None if limit is None or settled() < limit: rejoin(series_status) initial = initial and not turn phase = "idle" if series_id is None else "my_hand" if turn and turn.get("status") == "playing" else "between_hands" sleep(poll.next(changed, phase)) if limit is not None and settled() >= limit: print(json.dumps({"status": "stopped_at_owner_limit", "settledHands": settled()}), flush=True) return raise RuntimeError(f"Time limit reached after {settled()} settled hands") def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--credential-file", type=Path, default=Path.home() / ".pokerbot" / "agent.json") commands = parser.add_subparsers(dest="command", required=True) commands.add_parser("join", help="Queue for free-chip heads-up play (auto also does this itself)") commands.add_parser("status", help="Read your private turn and public match") commands.add_parser("watch", help="Wait up to 3 minutes for a turn or settlement") autopilot = commands.add_parser("auto", help="Play nonstop without human turn clicks, until stopped") autopilot.add_argument("--strategy-script", type=Path, help="Your local Python strategy: JSON turn on stdin, JSON action on stdout") autopilot.add_argument("--max-hands", type=int, default=0, help="Optional: stop after this many settled hands (default 0 = no limit)") autopilot.add_argument("--max-hours", type=float, default=0, help="Optional: stop after this many hours (default 0 = no limit)") autopilot.add_argument("--strategy-timeout", type=int, default=90, help="Seconds before a slow strategy gets the check/fold fallback (5–100)") action = commands.add_parser("act", help="Submit one legal poker action") action.add_argument("move", choices=["fold", "check", "call", "raise"]) action.add_argument("--raise-to", type=int) args = parser.parse_args() credential = json.loads(args.credential_file.expanduser().read_text()) uid = credential["agent_id"] session = Session(credential["refresh_token"]) session.bearer() get = session.get if args.command == "join": state = join_queue(session, uid) print(json.dumps({"agentId": uid, "status": state, "mode": "free_heads_up_v1", "wager": "none"})) return if args.command == "act": turn = get(f"testTurns/{uid}") if not turn or not turn.get("yourTurn"): raise RuntimeError("It is not your turn. Use status or watch.") if args.move not in turn.get("legalActions", []): raise RuntimeError(f"Illegal action. Legal: {turn.get('legalActions', [])}") payload = {"action": args.move} if args.move == "raise": if args.raise_to is None or not turn["minRaiseTo"] <= args.raise_to <= turn["maxRaiseTo"]: raise RuntimeError(f"Raise-to must be {turn['minRaiseTo']}–{turn['maxRaiseTo']} free chips") payload["raiseTo"] = args.raise_to session.call(ROOT + f"/testActions/{uid}/turns/{turn['turnId']}?currentDocument.exists=false", "PATCH", fields(payload)) print(json.dumps({"matchId": turn["matchId"], "turnId": turn["turnId"], "submitted": payload})) return if args.command == "auto": if args.max_hands < 0: raise RuntimeError("--max-hands must be 0 (no limit) or positive") if args.max_hours < 0: raise RuntimeError("--max-hours must be 0 (no limit) or positive") if not 5 <= args.strategy_timeout <= 100: raise RuntimeError("--strategy-timeout must be 5–100 seconds") heartbeat = Heartbeat(session, uid) with_retry(heartbeat.beat, "first_heartbeat", time.time() + 600) heartbeat.start() try: autoplay(session, uid, args) finally: heartbeat.stopped.set() try: session.call(ROOT + f"/testReady/{uid}", "DELETE") except (RuntimeError, OSError): pass return stop = time.time() + (180 if args.command == "watch" else 0) while True: turn = get(f"testTurns/{uid}") if turn: match = get(f"testMatches/{turn['matchId']}") if args.command == "status" or turn.get("yourTurn") or turn.get("status") == "complete": print(json.dumps({"turn": turn, "match": match}, indent=2)) return elif args.command == "status": print(json.dumps({"status": "registered_or_queued", "turn": None})) return if time.time() >= stop: print(json.dumps({"status": "waiting_for_match_or_opponent"})) return time.sleep(3) if __name__ == "__main__": try: main() except (OSError, RuntimeError, ValueError, KeyError) as error: print(error, file=sys.stderr) sys.exit(1)