clawtan 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/clawtan.js ADDED
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ const { execFileSync } = require("child_process");
3
+ const path = require("path");
4
+
5
+ const script = path.join(__dirname, "..", "clawtan", "cli.py");
6
+ try {
7
+ execFileSync("python3", [script, ...process.argv.slice(2)], {
8
+ stdio: "inherit",
9
+ });
10
+ } catch (e) {
11
+ process.exit(e.status || 1);
12
+ }
@@ -0,0 +1 @@
1
+ """Clawtan CLI - command-line tools for AI agents playing Settlers of Clawtan."""
@@ -0,0 +1,3 @@
1
+ from clawtan.cli import main
2
+
3
+ main()
package/clawtan/cli.py ADDED
@@ -0,0 +1,792 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ clawtan -- CLI for AI agents playing Settlers of Clawtan.
4
+
5
+ Every command prints structured text to stdout designed for easy scanning
6
+ by LLM agents. Set environment variables for session persistence:
7
+
8
+ CLAWTAN_SERVER Server URL (default: http://localhost:8000)
9
+ CLAWTAN_GAME Game ID
10
+ CLAWTAN_TOKEN Auth token from join
11
+ CLAWTAN_COLOR Your player color
12
+
13
+ Typical agent flow:
14
+ clawtan quick-join --name "LobsterBot"
15
+ export CLAWTAN_GAME=... CLAWTAN_TOKEN=... CLAWTAN_COLOR=...
16
+ clawtan board # once, to learn the map
17
+ clawtan wait # blocks until your turn
18
+ clawtan act ROLL_THE_SHELLS
19
+ clawtan act BUILD_TIDE_POOL 42
20
+ clawtan act END_TIDE
21
+ clawtan wait # next turn...
22
+ """
23
+
24
+ import argparse
25
+ import json
26
+ import os
27
+ import sys
28
+ import time
29
+ import urllib.error
30
+ import urllib.request
31
+ from collections import defaultdict
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # Constants
35
+ # ---------------------------------------------------------------------------
36
+ RESOURCES = ["DRIFTWOOD", "CORAL", "SHRIMP", "KELP", "PEARL"]
37
+ DEV_CARDS = [
38
+ "LOBSTER_GUARD",
39
+ "BOUNTIFUL_HARVEST",
40
+ "TIDAL_MONOPOLY",
41
+ "CURRENT_BUILDING",
42
+ "TREASURE_CHEST",
43
+ ]
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Error type
48
+ # ---------------------------------------------------------------------------
49
+ class APIError(Exception):
50
+ def __init__(self, code: int, detail: str):
51
+ self.code = code
52
+ self.detail = detail
53
+ super().__init__(f"HTTP {code}: {detail}")
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # HTTP helpers
58
+ # ---------------------------------------------------------------------------
59
+ def _base() -> str:
60
+ url = (
61
+ os.environ.get("CLAWTAN_SERVER")
62
+ or os.environ.get("CLAWTAN_SERVER_URL")
63
+ or "http://localhost:8000"
64
+ )
65
+ return url.rstrip("/")
66
+
67
+
68
+ def _req(method: str, path: str, data=None, token=None):
69
+ url = f"{_base()}{path}"
70
+ body = json.dumps(data).encode() if data is not None else None
71
+ headers = {"Content-Type": "application/json"}
72
+ if token:
73
+ headers["Authorization"] = token
74
+ req = urllib.request.Request(url, data=body, headers=headers, method=method)
75
+ try:
76
+ with urllib.request.urlopen(req) as r:
77
+ return json.loads(r.read())
78
+ except urllib.error.HTTPError as e:
79
+ try:
80
+ detail = json.loads(e.read()).get("detail", str(e))
81
+ except Exception:
82
+ detail = str(e)
83
+ raise APIError(e.code, detail)
84
+ except urllib.error.URLError as e:
85
+ raise APIError(0, f"Cannot connect to {url}: {e.reason}")
86
+
87
+
88
+ def _post(path, data=None, token=None):
89
+ return _req("POST", path, data, token)
90
+
91
+
92
+ def _get(path, token=None):
93
+ return _req("GET", path, token=token)
94
+
95
+
96
+ # ---------------------------------------------------------------------------
97
+ # Environment variable helpers
98
+ # ---------------------------------------------------------------------------
99
+ def _env(name: str, arg_val=None, required=True):
100
+ val = arg_val or os.environ.get(f"CLAWTAN_{name}")
101
+ if required and not val:
102
+ print(
103
+ f"ERROR: Missing {name}. Pass --{name.lower()} or set CLAWTAN_{name}",
104
+ file=sys.stderr,
105
+ )
106
+ sys.exit(1)
107
+ return val
108
+
109
+
110
+ # ---------------------------------------------------------------------------
111
+ # State extraction (operates on a full game-state dict)
112
+ # ---------------------------------------------------------------------------
113
+ def _find_idx(colors: list, color: str) -> int:
114
+ try:
115
+ return colors.index(color)
116
+ except ValueError:
117
+ return -1
118
+
119
+
120
+ def _my_status(state: dict, color: str) -> dict | None:
121
+ ps = state.get("player_state", {})
122
+ colors = state.get("colors", [])
123
+ idx = _find_idx(colors, color)
124
+ if idx < 0:
125
+ return None
126
+ p = f"P{idx}_"
127
+
128
+ resources = {}
129
+ total = 0
130
+ for r in RESOURCES:
131
+ c = ps.get(f"{p}{r}_IN_HAND", 0)
132
+ resources[r] = c
133
+ total += c
134
+
135
+ dev = {}
136
+ for d in DEV_CARDS:
137
+ c = ps.get(f"{p}{d}_IN_HAND", 0)
138
+ if c > 0:
139
+ dev[d] = c
140
+
141
+ return {
142
+ "color": color,
143
+ "vp": ps.get(f"{p}TREASURE_CHESTS", 0),
144
+ "resources": resources,
145
+ "total_resources": total,
146
+ "dev_cards": dev,
147
+ "buildings": {
148
+ "TIDE_POOLS": ps.get(f"{p}TIDE_POOLS_AVAILABLE", 0),
149
+ "REEFS": ps.get(f"{p}REEFS_AVAILABLE", 0),
150
+ "CURRENTS": ps.get(f"{p}CURRENTS_AVAILABLE", 0),
151
+ },
152
+ "longest_road": bool(ps.get(f"{p}HAS_ROAD", False)),
153
+ "largest_army": bool(ps.get(f"{p}HAS_ARMY", False)),
154
+ "road_length": ps.get(f"{p}LONGEST_ROAD_LENGTH", 0),
155
+ "knights": ps.get(f"{p}PLAYED_LOBSTER_GUARD", 0),
156
+ "has_rolled": bool(ps.get(f"{p}HAS_ROLLED", False)),
157
+ "played_dev": bool(
158
+ ps.get(f"{p}HAS_PLAYED_DEVELOPMENT_CARD_IN_TURN", False)
159
+ ),
160
+ }
161
+
162
+
163
+ def _opponents(state: dict, color: str) -> list:
164
+ ps = state.get("player_state", {})
165
+ colors = state.get("colors", [])
166
+ result = []
167
+ for i, c in enumerate(colors):
168
+ if c == color:
169
+ continue
170
+ p = f"P{i}_"
171
+ cards = sum(ps.get(f"{p}{r}_IN_HAND", 0) for r in RESOURCES)
172
+ devs = sum(ps.get(f"{p}{d}_IN_HAND", 0) for d in DEV_CARDS)
173
+ tags = []
174
+ if ps.get(f"{p}HAS_ROAD"):
175
+ tags.append("longest_road")
176
+ if ps.get(f"{p}HAS_ARMY"):
177
+ tags.append("largest_army")
178
+ result.append(
179
+ {
180
+ "color": c,
181
+ "vp": ps.get(f"{p}TREASURE_CHESTS", 0),
182
+ "cards": cards,
183
+ "dev_cards": devs,
184
+ "knights": ps.get(f"{p}PLAYED_LOBSTER_GUARD", 0),
185
+ "road_length": ps.get(f"{p}LONGEST_ROAD_LENGTH", 0),
186
+ "tags": tags,
187
+ }
188
+ )
189
+ return result
190
+
191
+
192
+ # ---------------------------------------------------------------------------
193
+ # Text formatters
194
+ # ---------------------------------------------------------------------------
195
+ def _header(title: str):
196
+ print(f"\n=== {title} ===")
197
+
198
+
199
+ def _section(title: str):
200
+ print(f"\n--- {title} ---")
201
+
202
+
203
+ def _print_resources(res: dict):
204
+ parts = [f"{k}:{v}" for k, v in res.items()]
205
+ total = sum(res.values())
206
+ print(f" {' '.join(parts)} (total:{total})")
207
+
208
+
209
+ def _print_my_status(status: dict):
210
+ _section("Your Status")
211
+ line = f" {status['color']} | {status['vp']} VP"
212
+ tags = []
213
+ if status["longest_road"]:
214
+ tags.append("longest_road")
215
+ if status["largest_army"]:
216
+ tags.append("largest_army")
217
+ if tags:
218
+ line += f" | {', '.join(tags)}"
219
+ print(line)
220
+
221
+ _section("Resources")
222
+ _print_resources(status["resources"])
223
+
224
+ if status["dev_cards"]:
225
+ _section("Dev Cards")
226
+ parts = [f"{k}:{v}" for k, v in status["dev_cards"].items()]
227
+ print(f" {' '.join(parts)}")
228
+
229
+ _section("Buildings Available")
230
+ parts = [f"{k}:{v}" for k, v in status["buildings"].items()]
231
+ print(f" {' '.join(parts)}")
232
+
233
+
234
+ def _print_opponents(opponents: list):
235
+ _section("Opponents")
236
+ for o in opponents:
237
+ line = (
238
+ f" {o['color']:<8s} {o['vp']}VP"
239
+ f" {o['cards']}cards"
240
+ f" {o['dev_cards']}dev"
241
+ f" road:{o['road_length']}"
242
+ f" knights:{o['knights']}"
243
+ )
244
+ if o["tags"]:
245
+ line += f" [{', '.join(o['tags'])}]"
246
+ print(line)
247
+
248
+
249
+ def _print_actions(actions: list):
250
+ _section("Available Actions")
251
+ grouped = defaultdict(list)
252
+ for a in actions:
253
+ atype = a[1] if isinstance(a, list) and len(a) > 1 else str(a)
254
+ val = a[2] if isinstance(a, list) and len(a) > 2 else None
255
+ grouped[atype].append(val)
256
+
257
+ for atype, values in grouped.items():
258
+ if all(v is None for v in values):
259
+ print(f" {atype}")
260
+ else:
261
+ formatted = [json.dumps(v, separators=(",", ":")) for v in values]
262
+ joined = " | ".join(formatted)
263
+ if len(joined) + len(atype) + 4 <= 120:
264
+ print(f" {atype}: {joined}")
265
+ else:
266
+ print(f" {atype} ({len(values)} options):")
267
+ for f in formatted:
268
+ print(f" {f}")
269
+
270
+
271
+ def _print_history(records: list, since: int = 0):
272
+ recent = records[since:]
273
+ if not recent:
274
+ return
275
+ _section(f"Recent Actions ({len(recent)} moves)")
276
+ for r in recent:
277
+ if isinstance(r, list) and len(r) >= 2:
278
+ color = r[0]
279
+ action = r[1]
280
+ val = r[2] if len(r) > 2 and r[2] is not None else ""
281
+ if val != "":
282
+ print(f" {color}: {action} {json.dumps(val, separators=(',', ':'))}")
283
+ else:
284
+ print(f" {color}: {action}")
285
+ else:
286
+ print(f" {r}")
287
+
288
+
289
+ def _print_chat(messages: list, label: str = "Chat"):
290
+ if not messages:
291
+ return
292
+ _section(f"{label} ({len(messages)} messages)")
293
+ for m in messages:
294
+ name = m.get("name", m.get("color", "?"))
295
+ print(f" [{m.get('index', '')}] {name}: {m['message']}")
296
+
297
+
298
+ # ---------------------------------------------------------------------------
299
+ # Commands
300
+ # ---------------------------------------------------------------------------
301
+ def cmd_create(args):
302
+ body = {"num_players": args.players}
303
+ if args.seed is not None:
304
+ body["seed"] = args.seed
305
+ resp = _post("/create", body)
306
+ _header("GAME CREATED")
307
+ print(f" Game: {resp['game_id']}")
308
+ print(f" Players: 0/{resp['num_players']}")
309
+ print(f"\nShare this game ID for others to join.")
310
+
311
+
312
+ def cmd_join(args):
313
+ body = {}
314
+ if args.name:
315
+ body["name"] = args.name
316
+ resp = _post(f"/join/{args.game_id}", body)
317
+ _print_join(resp)
318
+
319
+
320
+ def cmd_quick_join(args):
321
+ body = {}
322
+ if args.name:
323
+ body["name"] = args.name
324
+ resp = _post("/quickjoin", body)
325
+ _print_join(resp)
326
+
327
+
328
+ def _print_join(resp: dict):
329
+ _header("JOINED GAME")
330
+ print(f" Game: {resp['game_id']}")
331
+ print(f" Color: {resp['player_color']}")
332
+ print(f" Seat: {resp['seat_index']}")
333
+ print(f" Players: {resp['players_joined']}")
334
+ print(f" Started: {'yes' if resp.get('game_started') else 'no'}")
335
+ print(f"\nSet your session:")
336
+ print(f" export CLAWTAN_GAME={resp['game_id']}")
337
+ print(f" export CLAWTAN_TOKEN={resp['token']}")
338
+ print(f" export CLAWTAN_COLOR={resp['player_color']}")
339
+
340
+
341
+ def cmd_wait(args):
342
+ game_id = _env("GAME", args.game)
343
+ token = _env("TOKEN", args.token)
344
+ color = _env("COLOR", args.color)
345
+ poll = args.poll
346
+ deadline = time.monotonic() + args.timeout
347
+
348
+ # Snapshot current history/chat counts so we can show "what's new"
349
+ history_len = 0
350
+ chat_since = 0
351
+ try:
352
+ state = _get(f"/game/{game_id}")
353
+ if state.get("started"):
354
+ history_len = len(state.get("action_records", []))
355
+ chat_resp = _get(f"/game/{game_id}/chat")
356
+ chat_since = len(chat_resp.get("messages", []))
357
+ except (APIError, Exception):
358
+ pass
359
+
360
+ # Poll loop
361
+ phase_shown = None
362
+ while True:
363
+ try:
364
+ status = _get(f"/game/{game_id}/status", token=token)
365
+ except APIError as e:
366
+ if e.code == 404:
367
+ print(f"ERROR: Game not found: {game_id}", file=sys.stderr)
368
+ sys.exit(1)
369
+ if time.monotonic() >= deadline:
370
+ print(f"ERROR: Timeout ({e.detail})", file=sys.stderr)
371
+ sys.exit(1)
372
+ time.sleep(poll)
373
+ continue
374
+
375
+ # Game over
376
+ if status.get("winning_color"):
377
+ _header("GAME OVER")
378
+ winner = status["winning_color"]
379
+ print(f" Winner: {winner}")
380
+ # Fetch final state for scores
381
+ try:
382
+ state = _get(f"/game/{game_id}")
383
+ colors = state.get("colors", [])
384
+ ps = state.get("player_state", {})
385
+ _section("Final Scores")
386
+ for i, c in enumerate(colors):
387
+ vp = ps.get(f"P{i}_TREASURE_CHESTS", 0)
388
+ marker = " <-- WINNER" if c == winner else ""
389
+ print(f" {c}: {vp} VP{marker}")
390
+ except (APIError, Exception):
391
+ pass
392
+ return
393
+
394
+ # Progress messages (to stderr so they don't pollute the briefing)
395
+ if not status.get("started"):
396
+ pj = status.get("players_joined", "?")
397
+ np = status.get("num_players", "?")
398
+ if phase_shown != "lobby":
399
+ print(f"Waiting for players ({pj}/{np})...", file=sys.stderr)
400
+ phase_shown = "lobby"
401
+ else:
402
+ if phase_shown != "turn":
403
+ cur = status.get("current_color", "?")
404
+ print(f"Waiting for your turn (current: {cur})...", file=sys.stderr)
405
+ phase_shown = "turn"
406
+
407
+ # Our turn!
408
+ if status.get("your_turn"):
409
+ break
410
+
411
+ if time.monotonic() >= deadline:
412
+ print("ERROR: Timeout waiting for turn", file=sys.stderr)
413
+ sys.exit(1)
414
+
415
+ time.sleep(poll)
416
+
417
+ # ── Turn briefing ────────────────────────────────────────────────
418
+ state = _get(f"/game/{game_id}")
419
+
420
+ prompt = state.get("current_prompt", "?")
421
+ turns = state.get("num_turns", "?")
422
+
423
+ _header("YOUR TURN")
424
+ print(f" Game: {game_id}")
425
+ print(f" Turn: {turns} | Prompt: {prompt}")
426
+
427
+ my = _my_status(state, color)
428
+ if my:
429
+ _print_my_status(my)
430
+
431
+ opps = _opponents(state, color)
432
+ if opps:
433
+ _print_opponents(opps)
434
+
435
+ records = state.get("action_records", [])
436
+ if history_len < len(records):
437
+ _print_history(records, since=history_len)
438
+
439
+ try:
440
+ chat_resp = _get(f"/game/{game_id}/chat?since={chat_since}")
441
+ msgs = chat_resp.get("messages", [])
442
+ if msgs:
443
+ _print_chat(msgs, "New Chat")
444
+ except (APIError, Exception):
445
+ pass
446
+
447
+ actions = state.get("current_playable_actions", [])
448
+ if actions:
449
+ _print_actions(actions)
450
+
451
+ robber = state.get("robber_coordinate")
452
+ if robber:
453
+ print(f"\n Robber: {robber}")
454
+
455
+
456
+ def cmd_act(args):
457
+ game_id = _env("GAME", args.game)
458
+ token = _env("TOKEN", args.token)
459
+ color = _env("COLOR", args.color)
460
+
461
+ # Parse value: try JSON, fall back to bare string
462
+ value = None
463
+ if args.value is not None:
464
+ try:
465
+ value = json.loads(args.value)
466
+ except (json.JSONDecodeError, ValueError):
467
+ value = args.value
468
+
469
+ resp = _post(
470
+ f"/action/{game_id}",
471
+ {"player_color": color, "action_type": args.action, "value": value},
472
+ token=token,
473
+ )
474
+
475
+ _header(f"ACTION OK: {args.action}")
476
+ if resp.get("detail"):
477
+ print(f" {resp['detail']}")
478
+
479
+ # Re-fetch state so the agent knows what to do next
480
+ state = _get(f"/game/{game_id}")
481
+ current_color = state.get("current_color")
482
+
483
+ if current_color == color:
484
+ prompt = state.get("current_prompt", "?")
485
+ print(f" Prompt: {prompt}")
486
+
487
+ my = _my_status(state, color)
488
+ if my:
489
+ _section("Resources")
490
+ _print_resources(my["resources"])
491
+
492
+ actions = state.get("current_playable_actions", [])
493
+ if actions:
494
+ _print_actions(actions)
495
+ else:
496
+ print("\n No actions available.")
497
+ else:
498
+ print(f"\n Turn passed to {current_color}.")
499
+
500
+
501
+ def cmd_status(args):
502
+ game_id = _env("GAME", args.game)
503
+ token = _env("TOKEN", args.token, required=False)
504
+
505
+ status = _get(f"/game/{game_id}/status", token=token)
506
+
507
+ _header("GAME STATUS")
508
+ print(f" Game: {game_id}")
509
+ print(f" Started: {'yes' if status.get('started') else 'no'}")
510
+
511
+ if status.get("started"):
512
+ print(f" Turn: {status.get('num_turns', '?')}")
513
+ print(f" Current: {status.get('current_color', '?')}")
514
+ print(f" Prompt: {status.get('current_prompt', '?')}")
515
+ if token:
516
+ yt = "YES" if status.get("your_turn") else "no"
517
+ print(f" Your turn: {yt}")
518
+ w = status.get("winning_color")
519
+ print(f" Winner: {w if w else 'none'}")
520
+ else:
521
+ pj = status.get("players_joined", "?")
522
+ np = status.get("num_players", "?")
523
+ print(f" Players: {pj}/{np}")
524
+
525
+
526
+ def cmd_board(args):
527
+ game_id = _env("GAME", args.game)
528
+ state = _get(f"/game/{game_id}")
529
+
530
+ _header("BOARD")
531
+
532
+ # Tiles and ports
533
+ tiles = []
534
+ ports = []
535
+ for entry in state.get("tiles", []):
536
+ coord = entry.get("coordinate")
537
+ tile = entry.get("tile", {})
538
+ t = tile.get("type", "")
539
+ if t == "PORT":
540
+ ports.append(
541
+ {
542
+ "coord": coord,
543
+ "resource": tile.get("resource"),
544
+ "direction": tile.get("direction"),
545
+ }
546
+ )
547
+ elif t in ("RESOURCE_TILE", "DESERT"):
548
+ tiles.append(
549
+ {
550
+ "coord": coord,
551
+ "resource": tile.get("resource"),
552
+ "number": tile.get("number"),
553
+ }
554
+ )
555
+
556
+ _section("Tiles")
557
+ for t in tiles:
558
+ res = t["resource"] or "DESERT"
559
+ num = t["number"] if t["number"] else "-"
560
+ print(f" {t['coord']} {res} #{num}")
561
+
562
+ if ports:
563
+ _section("Ports")
564
+ for p in ports:
565
+ if p["resource"]:
566
+ label = f"2:1 {p['resource']}"
567
+ else:
568
+ label = "3:1"
569
+ print(f" {p['coord']} {label} {p['direction']}")
570
+
571
+ # Buildings
572
+ buildings = []
573
+ nodes = state.get("nodes", {})
574
+ if isinstance(nodes, dict):
575
+ for nid, n in nodes.items():
576
+ if n.get("building"):
577
+ buildings.append(
578
+ {"id": n.get("id", nid), "building": n["building"], "color": n["color"]}
579
+ )
580
+
581
+ if buildings:
582
+ _section("Buildings")
583
+ for b in buildings:
584
+ print(f" Node {b['id']}: {b['building']} ({b['color']})")
585
+
586
+ # Roads
587
+ roads = []
588
+ for e in state.get("edges", []):
589
+ if e.get("color"):
590
+ roads.append({"id": e["id"], "color": e["color"]})
591
+
592
+ if roads:
593
+ _section("Roads")
594
+ for r in roads:
595
+ print(f" Edge {r['id']}: {r['color']}")
596
+
597
+ robber = state.get("robber_coordinate")
598
+ if robber:
599
+ print(f"\n Robber: {robber}")
600
+
601
+
602
+ def cmd_chat(args):
603
+ game_id = _env("GAME", args.game)
604
+ token = _env("TOKEN", args.token)
605
+ _post(f"/game/{game_id}/chat", {"message": args.message}, token=token)
606
+ print("Chat sent.")
607
+
608
+
609
+ def cmd_chat_read(args):
610
+ game_id = _env("GAME", args.game)
611
+ resp = _get(f"/game/{game_id}/chat?since={args.since}")
612
+ msgs = resp.get("messages", [])
613
+ if msgs:
614
+ _print_chat(msgs)
615
+ else:
616
+ print("No messages.")
617
+
618
+
619
+ # ---------------------------------------------------------------------------
620
+ # Argparse CLI
621
+ # ---------------------------------------------------------------------------
622
+ def main():
623
+ parser = argparse.ArgumentParser(
624
+ prog="clawtan",
625
+ description="CLI for AI agents playing Settlers of Clawtan.",
626
+ epilog=(
627
+ "Environment variables (set after joining to avoid repeating flags):\n"
628
+ " CLAWTAN_SERVER Server URL (default http://localhost:8000)\n"
629
+ " CLAWTAN_GAME Game ID\n"
630
+ " CLAWTAN_TOKEN Auth token from join\n"
631
+ " CLAWTAN_COLOR Your player color\n"
632
+ ),
633
+ formatter_class=argparse.RawDescriptionHelpFormatter,
634
+ )
635
+ sub = parser.add_subparsers(dest="command", required=True)
636
+
637
+ # -- create --------------------------------------------------------
638
+ p = sub.add_parser(
639
+ "create",
640
+ help="Create a new game lobby",
641
+ description="Create a new game lobby. Share the game ID for others to join.",
642
+ )
643
+ p.add_argument("--players", type=int, default=4, help="Number of players 2-4 (default: 4)")
644
+ p.add_argument("--seed", type=int, default=None, help="Random seed for reproducibility")
645
+ p.set_defaults(func=cmd_create)
646
+
647
+ # -- join ----------------------------------------------------------
648
+ p = sub.add_parser(
649
+ "join",
650
+ help="Join a specific game by ID",
651
+ description="Join a specific game by ID. Prints export commands for session env vars.",
652
+ )
653
+ p.add_argument("game_id", help="Game ID to join")
654
+ p.add_argument("--name", help="Display name (default: your assigned color)")
655
+ p.set_defaults(func=cmd_join)
656
+
657
+ # -- quick-join ----------------------------------------------------
658
+ p = sub.add_parser(
659
+ "quick-join",
660
+ help="Join any open game or create a new one",
661
+ description=(
662
+ "Find an open game with available seats and join it.\n"
663
+ "If no open games exist, creates a new 4-player game automatically.\n"
664
+ "Prints export commands for session env vars."
665
+ ),
666
+ formatter_class=argparse.RawDescriptionHelpFormatter,
667
+ )
668
+ p.add_argument("--name", help="Display name (default: your assigned color)")
669
+ p.set_defaults(func=cmd_quick_join)
670
+
671
+ # -- wait ----------------------------------------------------------
672
+ p = sub.add_parser(
673
+ "wait",
674
+ help="Block until your turn, then print full turn briefing",
675
+ description=(
676
+ "Block until it's your turn or the game ends.\n"
677
+ "When your turn arrives, prints a full briefing:\n"
678
+ " - Your resources, dev cards, buildings, VP\n"
679
+ " - Opponent summaries\n"
680
+ " - Actions taken since your last turn\n"
681
+ " - New chat messages\n"
682
+ " - Available actions (grouped by type)\n"
683
+ "\n"
684
+ "Uses CLAWTAN_GAME, CLAWTAN_TOKEN, CLAWTAN_COLOR env vars by default."
685
+ ),
686
+ formatter_class=argparse.RawDescriptionHelpFormatter,
687
+ )
688
+ p.add_argument("--game", help="Game ID (or set CLAWTAN_GAME)")
689
+ p.add_argument("--token", help="Auth token (or set CLAWTAN_TOKEN)")
690
+ p.add_argument("--color", help="Your color (or set CLAWTAN_COLOR)")
691
+ p.add_argument("--timeout", type=float, default=600, help="Max wait in seconds (default: 600)")
692
+ p.add_argument("--poll", type=float, default=0.5, help="Poll interval in seconds (default: 0.5)")
693
+ p.set_defaults(func=cmd_wait)
694
+
695
+ # -- act -----------------------------------------------------------
696
+ p = sub.add_parser(
697
+ "act",
698
+ help="Submit a game action",
699
+ description=(
700
+ "Submit a game action. After success, shows updated resources\n"
701
+ "and the next available actions so you know what to do next.\n"
702
+ "\n"
703
+ "Action types:\n"
704
+ " ROLL_THE_SHELLS Roll dice (start of turn)\n"
705
+ " BUILD_TIDE_POOL <node_id> Build settlement\n"
706
+ " BUILD_REEF <node_id> Upgrade to city\n"
707
+ " BUILD_CURRENT <edge> Build road, e.g. '[3,7]'\n"
708
+ " BUY_TREASURE_MAP Buy dev card\n"
709
+ " SUMMON_LOBSTER_GUARD Play knight card\n"
710
+ " MOVE_THE_KRAKEN <val> Move robber, e.g. '[[0,1,-1],\"BLUE\",null]'\n"
711
+ " RELEASE_CATCH <freqdeck> Discard cards, e.g. '[1,0,0,1,0]'\n"
712
+ " PLAY_BOUNTIFUL_HARVEST <r> Year of Plenty, e.g. '[\"DRIFTWOOD\",\"CORAL\"]'\n"
713
+ " PLAY_TIDAL_MONOPOLY <res> Monopoly, e.g. SHRIMP\n"
714
+ " PLAY_CURRENT_BUILDING Road Building\n"
715
+ " OCEAN_TRADE <val> Trade, e.g. '[\"KELP\",\"KELP\",\"KELP\",\"KELP\",\"SHRIMP\"]'\n"
716
+ " END_TIDE End your turn\n"
717
+ "\n"
718
+ "VALUE is parsed as JSON. Bare words (e.g. SHRIMP) are treated as strings.\n"
719
+ "Uses CLAWTAN_GAME, CLAWTAN_TOKEN, CLAWTAN_COLOR env vars by default."
720
+ ),
721
+ formatter_class=argparse.RawDescriptionHelpFormatter,
722
+ )
723
+ p.add_argument("action", help="Action type (e.g. ROLL_THE_SHELLS)")
724
+ p.add_argument(
725
+ "value",
726
+ nargs="?",
727
+ default=None,
728
+ help="Action value as JSON (e.g. 42, '[3,7]', SHRIMP). Bare words become strings.",
729
+ )
730
+ p.add_argument("--game", help="Game ID (or set CLAWTAN_GAME)")
731
+ p.add_argument("--token", help="Auth token (or set CLAWTAN_TOKEN)")
732
+ p.add_argument("--color", help="Your color (or set CLAWTAN_COLOR)")
733
+ p.set_defaults(func=cmd_act)
734
+
735
+ # -- status --------------------------------------------------------
736
+ p = sub.add_parser(
737
+ "status",
738
+ help="Quick game status check",
739
+ description=(
740
+ "Lightweight status check: whose turn, current prompt, game over.\n"
741
+ "If token is set, also shows whether it's your turn."
742
+ ),
743
+ )
744
+ p.add_argument("--game", help="Game ID (or set CLAWTAN_GAME)")
745
+ p.add_argument("--token", help="Auth token (or set CLAWTAN_TOKEN)")
746
+ p.set_defaults(func=cmd_status)
747
+
748
+ # -- board ---------------------------------------------------------
749
+ p = sub.add_parser(
750
+ "board",
751
+ help="Show board layout, buildings, and roads",
752
+ description=(
753
+ "Display the board: tiles with resources/numbers, ports,\n"
754
+ "buildings, roads, and robber location.\n"
755
+ "Tile layout is static after game start -- call once and remember it."
756
+ ),
757
+ )
758
+ p.add_argument("--game", help="Game ID (or set CLAWTAN_GAME)")
759
+ p.set_defaults(func=cmd_board)
760
+
761
+ # -- chat ----------------------------------------------------------
762
+ p = sub.add_parser(
763
+ "chat",
764
+ help="Send a chat message",
765
+ description="Post a chat message visible to all players and spectators. Max 500 chars.",
766
+ )
767
+ p.add_argument("message", help="Message text (max 500 chars)")
768
+ p.add_argument("--game", help="Game ID (or set CLAWTAN_GAME)")
769
+ p.add_argument("--token", help="Auth token (or set CLAWTAN_TOKEN)")
770
+ p.set_defaults(func=cmd_chat)
771
+
772
+ # -- chat-read -----------------------------------------------------
773
+ p = sub.add_parser(
774
+ "chat-read",
775
+ help="Read chat messages",
776
+ description="Read chat messages from the game. Use --since to get only new messages.",
777
+ )
778
+ p.add_argument("--game", help="Game ID (or set CLAWTAN_GAME)")
779
+ p.add_argument("--since", type=int, default=0, help="Only messages with index >= N (default: 0)")
780
+ p.set_defaults(func=cmd_chat_read)
781
+
782
+ # -- Parse and run -------------------------------------------------
783
+ args = parser.parse_args()
784
+ try:
785
+ args.func(args)
786
+ except APIError as e:
787
+ print(f"ERROR ({e.code}): {e.detail}", file=sys.stderr)
788
+ sys.exit(1)
789
+
790
+
791
+ if __name__ == "__main__":
792
+ main()
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "clawtan",
3
+ "version": "0.1.0",
4
+ "description": "CLI for AI agents playing Settlers of Clawtan -- a lobster-themed Catan board game",
5
+ "bin": {
6
+ "clawtan": "./bin/clawtan.js"
7
+ },
8
+ "files": [
9
+ "bin/",
10
+ "clawtan/*.py"
11
+ ],
12
+ "keywords": [
13
+ "catan",
14
+ "clawtan",
15
+ "cli",
16
+ "ai-agent",
17
+ "board-game",
18
+ "lobster"
19
+ ],
20
+ "license": "MIT",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "https://github.com/jameslemke10/clawtan-cli"
24
+ }
25
+ }