"""A room's half of the room protocol (docs/games.md ยง8.3), for any game's referee. SS_ROOM says who holds each seat; ask() gets a move from a bot child, from a person or agent over stdin, or from the game's own fallback (auto). room = Room(start) start(folder) -> host.Child, the game's way to run a bot (a route's or field/) move = room.ask(seat, obs, legal, auto=lambda: ..., bot=) move = room.ask(seat, obs, legal, auto, accept=lambda a: ...) # legal holds templates (free text): accept says # what an answer means, or None to keep waiting room.tell(seat, obs); room.event(data); room.end(result, scores); room.close() Lines to the platform start with '@shoal ' and are flushed one by one; the referee's own prints go to stderr, so a library printing as it loads cannot break a line. A person or agent who does not answer within the limit (SS_ROOM "limit", 30 s by default; ask(..., limit=s) sets one request's, e.g. a speech's) gets auto's move. {"control": ...} re-asks the seat with a new version, so a late answer from the old controller is dropped; {"stop": true} makes ask() raise Stop, and the referee writes what it has and ends. """ import json, os, queue, sys, threading, time class Stop(Exception): pass def log(*a): print('room:', *a, file=sys.stderr, flush=True) class Room: def __init__(self, start, field='field'): conf = json.loads(os.environ.get('SS_ROOM') or '{}') self.seats = {str(k): v for k, v in conf.get('seats', {}).items()} self.limit = float(conf.get('limit', 30)) self.start, self.field, self.bots = start, field, {} self.version, self.stopped = 0, False self.inbox, self.lock = queue.Queue(), threading.Lock() self.out, sys.stdout = sys.stdout, sys.stderr # prints, ours or a library's, must not look like protocol threading.Thread(target=self._read, daemon=True).start() def say(self, msg): with self.lock: # the reader thread speaks too (control events) self.out.write('@shoal ' + json.dumps(msg, separators=(',', ':')) + '\n') self.out.flush() def tell(self, seat, obs): self.say({'to': seat, 'obs': obs}) def event(self, data): self.say({'event': data}) def end(self, result, scores): self.say({'end': {'result': result, 'scores': scores}}) def label(self, seat): s = self.seats.get(seat) or {} return s.get('name') or s.get('route') or s.get('field') or os.path.basename((s.get('dir') or '').rstrip('/')) or s.get('kind') or 'auto' def kind(self, seat): return (self.seats.get(seat) or {}).get('kind') or 'auto' def ask(self, seat, obs, legal, auto, bot=None, accept=None, limit=None): ok = accept or (lambda a: a if a in legal else None) while True: if self.stopped: raise Stop spec = self.seats.get(seat) or {} kind = spec.get('kind') if kind == 'bot': child = self._bot(spec) move = child.ask(bot if bot is not None else {'obs': obs, 'legal': legal}) if child else None move = ok(move) if move is not None else None return auto() if move is None else move # a late, broken or illegal bot move: auto plays it if kind not in ('human', 'agent'): return auto() self._drain() # answers and notices from before this request are stale if self.stopped: # a stop drained with them still counts raise Stop self.version += 1 v = self.version self.say({'to': seat, 'version': v, 'obs': obs, 'legal': legal}) deadline = time.monotonic() + (limit or self.limit) while True: try: msg = self.inbox.get(timeout=max(0.0, deadline - time.monotonic())) except queue.Empty: log(f'seat {seat} out of time at version {v}: auto plays') return auto() if msg.get('stop'): raise Stop c = msg.get('control') if isinstance(c, dict) and str(c.get('seat')) == seat: break # a new controller: ask again, with a new version a = msg.get('act') if isinstance(a, dict) and str(a.get('seat')) == seat and a.get('version') == v: move = ok(a.get('action')) if move is not None: return move if a is not None: log(f'ignored act {json.dumps(a)[:120]}: asking seat {seat} at version {v}') def close(self): for c in self.bots.values(): if c: c.close() def _read(self): for line in sys.stdin: try: msg = json.loads(line) except ValueError: log('not JSON:', line.strip()[:120]) continue if not isinstance(msg, dict): continue if msg.get('stop'): self.stopped = True c = msg.get('control') if isinstance(c, dict) and 'seat' in c: # applied at once: the seat may not be the one being asked seat = str(c['seat']) self.seats[seat] = {k: v for k, v in c.items() if k != 'seat'} self.event({'type': 'control', 'seat': seat, 'kind': self.kind(seat), 'name': self.label(seat)}) self.inbox.put(msg) def _drain(self): while True: try: msg = self.inbox.get_nowait() except queue.Empty: return if msg.get('act') is not None: log(f'ignored act {json.dumps(msg["act"])[:120]}: stale') def _bot(self, spec): """The child for a bot seat, one per folder (as the checks share one between seats); None if it cannot start.""" if spec.get('field'): folder = os.path.join(self.field, spec['field']) else: folder = os.path.join(os.environ.get('SS_INPUT', '.'), spec.get('dir') or spec.get('route') or '') if folder not in self.bots: try: self.bots[folder] = self.start(folder) except (Exception, SystemExit) as e: # a missing bot is played by auto rather than ending the room log(f'no bot at {folder} ({e}): auto plays that seat') self.bots[folder] = None return self.bots[folder]