| 1 | """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; |
| 2 | ask() gets a move from a bot child, from a person or agent over stdin, or from the game's own fallback (auto). |
| 3 | |
| 4 | room = Room(start) start(folder) -> host.Child, the game's way to run a bot (a route's or field/<name>) |
| 5 | move = room.ask(seat, obs, legal, auto=lambda: ..., bot=<the bot child's message, in the game's own interface>) |
| 6 | move = room.ask(seat, obs, legal, auto, accept=lambda a: ...) # legal holds templates (free text): accept says |
| 7 | # what an answer means, or None to keep waiting |
| 8 | room.tell(seat, obs); room.event(data); room.end(result, scores); room.close() |
| 9 | |
| 10 | Lines to the platform start with '@shoal ' and are flushed one by one; the referee's own prints go to stderr, so a |
| 11 | library printing as it loads cannot break a line. A person or agent who does not answer within the limit (SS_ROOM |
| 12 | "limit", 30 s by default; ask(..., limit=s) sets one request's, e.g. a speech's) gets auto's move. {"control": ...} |
| 13 | re-asks the seat with a new version, so a late answer from the old controller is dropped; {"stop": true} makes ask() |
| 14 | raise Stop, and the referee writes what it has and ends. |
| 15 | """ |
| 16 | import json, os, queue, sys, threading, time |
| 17 | |
| 18 | |
| 19 | class Stop(Exception): |
| 20 | pass |
| 21 | |
| 22 | |
| 23 | def log(*a): |
| 24 | print('room:', *a, file=sys.stderr, flush=True) |
| 25 | |
| 26 | |
| 27 | class Room: |
| 28 | def __init__(self, start, field='field'): |
| 29 | conf = json.loads(os.environ.get('SS_ROOM') or '{}') |
| 30 | self.seats = {str(k): v for k, v in conf.get('seats', {}).items()} |
| 31 | self.limit = float(conf.get('limit', 30)) |
| 32 | self.start, self.field, self.bots = start, field, {} |
| 33 | self.version, self.stopped = 0, False |
| 34 | self.inbox, self.lock = queue.Queue(), threading.Lock() |
| 35 | self.out, sys.stdout = sys.stdout, sys.stderr |
| 36 | threading.Thread(target=self._read, daemon=True).start() |
| 37 | |
| 38 | def say(self, msg): |
| 39 | with self.lock: |
| 40 | self.out.write('@shoal ' + json.dumps(msg, separators=(',', ':')) + '\n') |
| 41 | self.out.flush() |
| 42 | |
| 43 | def tell(self, seat, obs): |
| 44 | self.say({'to': seat, 'obs': obs}) |
| 45 | |
| 46 | def event(self, data): |
| 47 | self.say({'event': data}) |
| 48 | |
| 49 | def end(self, result, scores): |
| 50 | self.say({'end': {'result': result, 'scores': scores}}) |
| 51 | |
| 52 | def label(self, seat): |
| 53 | s = self.seats.get(seat) or {} |
| 54 | 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' |
| 55 | |
| 56 | def kind(self, seat): |
| 57 | return (self.seats.get(seat) or {}).get('kind') or 'auto' |
| 58 | |
| 59 | def ask(self, seat, obs, legal, auto, bot=None, accept=None, limit=None): |
| 60 | ok = accept or (lambda a: a if a in legal else None) |
| 61 | while True: |
| 62 | if self.stopped: |
| 63 | raise Stop |
| 64 | spec = self.seats.get(seat) or {} |
| 65 | kind = spec.get('kind') |
| 66 | if kind == 'bot': |
| 67 | child = self._bot(spec) |
| 68 | move = child.ask(bot if bot is not None else {'obs': obs, 'legal': legal}) if child else None |
| 69 | move = ok(move) if move is not None else None |
| 70 | return auto() if move is None else move |
| 71 | if kind not in ('human', 'agent'): |
| 72 | return auto() |
| 73 | self._drain() |
| 74 | if self.stopped: |
| 75 | raise Stop |
| 76 | self.version += 1 |
| 77 | v = self.version |
| 78 | self.say({'to': seat, 'version': v, 'obs': obs, 'legal': legal}) |
| 79 | deadline = time.monotonic() + (limit or self.limit) |
| 80 | while True: |
| 81 | try: |
| 82 | msg = self.inbox.get(timeout=max(0.0, deadline - time.monotonic())) |
| 83 | except queue.Empty: |
| 84 | log(f'seat {seat} out of time at version {v}: auto plays') |
| 85 | return auto() |
| 86 | if msg.get('stop'): |
| 87 | raise Stop |
| 88 | c = msg.get('control') |
| 89 | if isinstance(c, dict) and str(c.get('seat')) == seat: |
| 90 | break |
| 91 | a = msg.get('act') |
| 92 | if isinstance(a, dict) and str(a.get('seat')) == seat and a.get('version') == v: |
| 93 | move = ok(a.get('action')) |
| 94 | if move is not None: |
| 95 | return move |
| 96 | if a is not None: |
| 97 | log(f'ignored act {json.dumps(a)[:120]}: asking seat {seat} at version {v}') |
| 98 | |
| 99 | def close(self): |
| 100 | for c in self.bots.values(): |
| 101 | if c: |
| 102 | c.close() |
| 103 | |
| 104 | def _read(self): |
| 105 | for line in sys.stdin: |
| 106 | try: |
| 107 | msg = json.loads(line) |
| 108 | except ValueError: |
| 109 | log('not JSON:', line.strip()[:120]) |
| 110 | continue |
| 111 | if not isinstance(msg, dict): |
| 112 | continue |
| 113 | if msg.get('stop'): |
| 114 | self.stopped = True |
| 115 | c = msg.get('control') |
| 116 | if isinstance(c, dict) and 'seat' in c: |
| 117 | seat = str(c['seat']) |
| 118 | self.seats[seat] = {k: v for k, v in c.items() if k != 'seat'} |
| 119 | self.event({'type': 'control', 'seat': seat, 'kind': self.kind(seat), 'name': self.label(seat)}) |
| 120 | self.inbox.put(msg) |
| 121 | |
| 122 | def _drain(self): |
| 123 | while True: |
| 124 | try: |
| 125 | msg = self.inbox.get_nowait() |
| 126 | except queue.Empty: |
| 127 | return |
| 128 | if msg.get('act') is not None: |
| 129 | log(f'ignored act {json.dumps(msg["act"])[:120]}: stale') |
| 130 | |
| 131 | def _bot(self, spec): |
| 132 | """The child for a bot seat, one per folder (as the checks share one between seats); None if it cannot start.""" |
| 133 | if spec.get('field'): |
| 134 | folder = os.path.join(self.field, spec['field']) |
| 135 | else: |
| 136 | folder = os.path.join(os.environ.get('SS_INPUT', '.'), spec.get('dir') or spec.get('route') or '') |
| 137 | if folder not in self.bots: |
| 138 | try: |
| 139 | self.bots[folder] = self.start(folder) |
| 140 | except (Exception, SystemExit) as e: |
| 141 | log(f'no bot at {folder} ({e}): auto plays that seat') |
| 142 | self.bots[folder] = None |
| 143 | return self.bots[folder] |