| 1 | """Play a room on a laptop, without the site: run a room command with SS_ROOM, print what it says (docs/games.md §8.3), |
| 2 | and answer its requests to human and agent seats, from the terminal or by rule. |
| 3 | |
| 4 | python3 kit/roomcli.py --room '{"seats": {...}}' [--auto] [--seat 0 --pick random] [--control 0=auto@6] [--stop 12] -- <command> |
| 5 | |
| 6 | --auto answers every request with the first legal action; --seat S (repeatable) answers seat S by --pick (first or |
| 7 | random) and leaves the other seats to the terminal. --control S=KIND@N: at the first request to seat S after N public |
| 8 | events, hand the seat to KIND (auto, agent, human), then send the old controller's late answer to that request, which |
| 9 | the room must ignore (it re-asks with a new version, or auto plays). --stop N sends {"stop": true} after N events. |
| 10 | SS_INPUT (a route's dir is under it) defaults to the current folder; --full prints whole messages. |
| 11 | """ |
| 12 | import argparse, json, os, random, subprocess, sys |
| 13 | |
| 14 | |
| 15 | def main(): |
| 16 | ap = argparse.ArgumentParser(description=__doc__.split('\n')[0]) |
| 17 | ap.add_argument('--room', default=os.environ.get('SS_ROOM', '{"seats": {}}'), help='SS_ROOM for the room') |
| 18 | ap.add_argument('--auto', action='store_true', help='answer every request with the first legal action') |
| 19 | ap.add_argument('--seat', action='append', default=[], help='a seat answered by --pick') |
| 20 | ap.add_argument('--pick', choices=['first', 'random'], default='first') |
| 21 | ap.add_argument('--control', action='append', default=[], help='S=KIND@N: hand seat S to KIND after N events') |
| 22 | ap.add_argument('--stop', type=int, help='send stop after N events') |
| 23 | ap.add_argument('--seed', type=int, default=0, help='for --pick random') |
| 24 | ap.add_argument('--full', action='store_true') |
| 25 | ap.add_argument('cmd', nargs=argparse.REMAINDER) |
| 26 | a = ap.parse_args() |
| 27 | cmd = a.cmd[1:] if a.cmd[:1] == ['--'] else a.cmd |
| 28 | if not cmd: |
| 29 | ap.error('give the room command after --') |
| 30 | controls = [] |
| 31 | for c in a.control: |
| 32 | seat, rest = c.split('=', 1) |
| 33 | kind, at = rest.split('@', 1) |
| 34 | controls.append({'seat': seat, 'kind': kind, 'at': int(at)}) |
| 35 | rnd = random.Random(a.seed) |
| 36 | env = {**os.environ, 'SS_ROOM': a.room, 'SS_INPUT': os.environ.get('SS_INPUT', os.getcwd())} |
| 37 | p = subprocess.Popen(cmd, env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, bufsize=1) |
| 38 | events = asked = answered = late = 0 |
| 39 | versions = [] |
| 40 | |
| 41 | def send(msg): |
| 42 | p.stdin.write(json.dumps(msg) + '\n') |
| 43 | p.stdin.flush() |
| 44 | print(' >>', json.dumps(msg)[:200]) |
| 45 | |
| 46 | for line in p.stdout: |
| 47 | if not line.startswith('@shoal '): |
| 48 | print(' log', line.rstrip()) |
| 49 | continue |
| 50 | msg = json.loads(line[7:]) |
| 51 | text = json.dumps(msg) |
| 52 | print(f'@shoal {text if a.full or len(text) <= 200 else text[:197] + "..."}') |
| 53 | if 'event' in msg: |
| 54 | events += 1 |
| 55 | if a.stop is not None and events == a.stop: |
| 56 | send({'stop': True}) |
| 57 | if 'end' in msg: |
| 58 | break |
| 59 | if 'version' not in msg: |
| 60 | continue |
| 61 | seat, v, legal = msg['to'], msg['version'], msg['legal'] |
| 62 | asked += 1 |
| 63 | assert not versions or v > versions[-1], 'versions must grow' |
| 64 | versions.append(v) |
| 65 | due = next((c for c in controls if c['seat'] == seat and events >= c['at']), None) |
| 66 | if due: |
| 67 | controls.remove(due) |
| 68 | send({'control': {'seat': seat, 'kind': due['kind'], 'name': f'{due["kind"]}-{seat}'}}) |
| 69 | send({'act': {'seat': seat, 'version': v, 'action': legal[-1]}}) |
| 70 | late += 1 |
| 71 | continue |
| 72 | if a.auto or seat in a.seat: |
| 73 | action = legal[0] if a.pick == 'first' or a.auto else rnd.choice(legal) |
| 74 | else: |
| 75 | print(f'seat {seat}, version {v}: obs {json.dumps(msg["obs"])[:300]}\nlegal: {" ".join(map(str, legal[:60]))}') |
| 76 | got = input('your move, or its index (blank: the first)> ').strip() |
| 77 | action = got if got in legal else legal[int(got)] if got.isdigit() and int(got) < len(legal) else got or legal[0] |
| 78 | send({'act': {'seat': seat, 'version': v, 'action': action}}) |
| 79 | answered += 1 |
| 80 | p.stdin.close() |
| 81 | code = p.wait() |
| 82 | print(f'roomcli: {events} events, {asked} requests (versions {versions[:1]}..{versions[-1:]}), {answered} answered, ' |
| 83 | f'{late} late answers sent, exit {code}') |
| 84 | sys.exit(code) |
| 85 | |
| 86 | |
| 87 | if __name__ == '__main__': |
| 88 | main() |