clawtan 0.2.0 → 0.2.2

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.
Files changed (2) hide show
  1. package/clawtan/cli.py +368 -45
  2. package/package.json +1 -1
package/clawtan/cli.py CHANGED
@@ -14,10 +14,24 @@ Typical agent flow:
14
14
  clawtan act END_TIDE
15
15
  clawtan wait # next turn...
16
16
 
17
+ Multi-player on one machine (each terminal):
18
+ clawtan join <GAME_ID> # join, note your assigned color
19
+ export CLAWTAN_COLOR=<COLOR> # lock this terminal to your player
20
+ clawtan wait # now uses the correct session
21
+
22
+ Or use --player on every call (works with separate exec calls):
23
+ clawtan --player BLUE wait
24
+ clawtan --player BLUE act ROLL_THE_SHELLS
25
+
26
+ Same color in multiple games — add --game:
27
+ clawtan --player RED wait --game <GAME_ID>
28
+
17
29
  Session lookup order (per field):
18
- 1. CLI flags (--game, --token, --color)
30
+ 1. CLI flags (--game, --token, --color, --player)
19
31
  2. Environment variables (CLAWTAN_GAME, CLAWTAN_TOKEN, CLAWTAN_COLOR)
20
- 3. Session file (~/.clawtan_session, override with CLAWTAN_SESSION_FILE)
32
+ 3. Session file: ~/.clawtan_sessions/<game>_<color>.json matched by
33
+ available hints, falling back to ~/.clawtan_session.
34
+ Override with CLAWTAN_SESSION_FILE.
21
35
  """
22
36
 
23
37
  import argparse
@@ -126,42 +140,97 @@ def _get(path, token=None):
126
140
  # ---------------------------------------------------------------------------
127
141
  # Session file helpers
128
142
  # ---------------------------------------------------------------------------
129
- def _session_path() -> str:
130
- return os.environ.get("CLAWTAN_SESSION_FILE") or os.path.expanduser("~/.clawtan_session")
143
+ _SESSIONS_DIR = os.path.expanduser("~/.clawtan_sessions")
131
144
 
132
145
 
133
146
  def _save_session(game_id: str, token: str, color: str):
134
- path = _session_path()
135
147
  data = {"GAME": game_id, "TOKEN": token, "COLOR": color}
148
+ os.makedirs(_SESSIONS_DIR, exist_ok=True)
149
+ path = os.path.join(_SESSIONS_DIR, f"{game_id}_{color}.json")
136
150
  try:
137
151
  with open(path, "w") as f:
138
152
  json.dump(data, f)
139
153
  except OSError as e:
140
154
  print(f"Warning: could not save session to {path}: {e}", file=sys.stderr)
155
+ default = os.environ.get("CLAWTAN_SESSION_FILE") or os.path.expanduser("~/.clawtan_session")
156
+ try:
157
+ with open(default, "w") as f:
158
+ json.dump(data, f)
159
+ except OSError:
160
+ pass
161
+
162
+
163
+ def _find_session(game_hint: str | None = None, color_hint: str | None = None) -> dict:
164
+ """Find a session file using available game/color hints."""
165
+ custom = os.environ.get("CLAWTAN_SESSION_FILE")
166
+ if custom:
167
+ try:
168
+ with open(custom) as f:
169
+ return json.load(f)
170
+ except (OSError, json.JSONDecodeError):
171
+ return {}
172
+
173
+ if game_hint and color_hint:
174
+ path = os.path.join(_SESSIONS_DIR, f"{game_hint}_{color_hint}.json")
175
+ try:
176
+ with open(path) as f:
177
+ return json.load(f)
178
+ except (OSError, json.JSONDecodeError):
179
+ pass
141
180
 
181
+ if (game_hint or color_hint) and os.path.isdir(_SESSIONS_DIR):
182
+ matches = []
183
+ for fname in os.listdir(_SESSIONS_DIR):
184
+ if not fname.endswith(".json"):
185
+ continue
186
+ try:
187
+ with open(os.path.join(_SESSIONS_DIR, fname)) as f:
188
+ data = json.load(f)
189
+ if game_hint and data.get("GAME") != game_hint:
190
+ continue
191
+ if color_hint and data.get("COLOR") != color_hint:
192
+ continue
193
+ matches.append(data)
194
+ except (OSError, json.JSONDecodeError):
195
+ continue
196
+ if len(matches) == 1:
197
+ return matches[0]
198
+ if matches:
199
+ return matches[0]
142
200
 
143
- def _load_session() -> dict:
144
- path = _session_path()
201
+ default = os.path.expanduser("~/.clawtan_session")
145
202
  try:
146
- with open(path) as f:
203
+ with open(default) as f:
147
204
  return json.load(f)
148
205
  except (OSError, json.JSONDecodeError):
149
206
  return {}
150
207
 
151
208
 
152
209
  # ---------------------------------------------------------------------------
153
- # Environment / session variable helpers
210
+ # Session resolution
154
211
  # ---------------------------------------------------------------------------
155
- def _env(name: str, arg_val=None, required=True):
156
- # 1. CLI flag
157
- val = arg_val
158
- # 2. Environment variable
159
- if not val:
160
- val = os.environ.get(f"CLAWTAN_{name}")
161
- # 3. Session file
212
+ def _resolve_session(arg_game=None, arg_token=None, arg_color=None):
213
+ """Resolve game, token, color from flags -> env vars -> session file.
214
+
215
+ Uses all available hints together to find the correct session file,
216
+ which avoids ambiguity when multiple games or players share a machine.
217
+ """
218
+ game = arg_game or os.environ.get("CLAWTAN_GAME") or None
219
+ token = arg_token or os.environ.get("CLAWTAN_TOKEN") or None
220
+ color = arg_color or os.environ.get("CLAWTAN_COLOR") or None
221
+
222
+ if not (game and token and color):
223
+ session = _find_session(game_hint=game, color_hint=color)
224
+ game = game or session.get("GAME")
225
+ token = token or session.get("TOKEN")
226
+ color = color or session.get("COLOR")
227
+
228
+ return game, token, color
229
+
230
+
231
+ def _require(name: str, val):
232
+ """Exit with error if a required session value is missing."""
162
233
  if not val:
163
- val = _load_session().get(name)
164
- if required and not val:
165
234
  print(
166
235
  f"ERROR: Missing {name}. Pass --{name.lower()}, set CLAWTAN_{name},"
167
236
  f" or run 'clawtan quick-join' to create a session.",
@@ -327,7 +396,7 @@ _ACTION_HINTS = {
327
396
  " CLI: clawtan act RELEASE_CATCH"
328
397
  ),
329
398
  "MOVE_THE_KRAKEN": (
330
- "Move robber: value = [coordinate, victim_color_or_null, null].\n"
399
+ "Move Kraken: value = [coordinate, victim_color_or_null, null].\n"
331
400
  " CLI: clawtan act MOVE_THE_KRAKEN '[[0,1,-1],\"BLUE\",null]'"
332
401
  ),
333
402
  "OCEAN_TRADE": (
@@ -342,7 +411,101 @@ _ACTION_HINTS = {
342
411
  }
343
412
 
344
413
 
345
- def _print_actions(actions: list, my_color: str | None = None):
414
+ def _player_network(state: dict, color: str) -> set:
415
+ """Return the set of node ID strings that belong to a player's network."""
416
+ nodes = state.get("nodes", {})
417
+ network = set()
418
+ for nid, node in nodes.items():
419
+ if node.get("color") == color:
420
+ network.add(str(nid))
421
+ for e in state.get("edges", []):
422
+ if e.get("color") == color:
423
+ eid = e.get("id")
424
+ if isinstance(eid, list) and len(eid) == 2:
425
+ network.add(str(eid[0]))
426
+ network.add(str(eid[1]))
427
+ return network
428
+
429
+
430
+ def _node_resource_label(state: dict, nid) -> str:
431
+ """Return a compact resource description for a node, e.g. 'SHRIMP(4), KELP(2)'."""
432
+ adj_tiles = state.get("adjacent_tiles", {})
433
+ tiles_info = adj_tiles.get(str(nid), [])
434
+ labels = []
435
+ for t in tiles_info:
436
+ res = t.get("resource")
437
+ num = t.get("number")
438
+ if res:
439
+ labels.append(f"{res}({num})" if num else res)
440
+ return ", ".join(labels)
441
+
442
+
443
+ def _edge_annotation(edge_val, state: dict, color: str) -> str:
444
+ """Annotate a BUILD_CURRENT edge with network context and destination resources."""
445
+ if not isinstance(edge_val, list) or len(edge_val) != 2 or not state:
446
+ return ""
447
+
448
+ a_str, b_str = str(edge_val[0]), str(edge_val[1])
449
+ network = _player_network(state, color)
450
+ nodes = state.get("nodes", {})
451
+
452
+ a_mine = a_str in network
453
+ b_mine = b_str in network
454
+
455
+ if a_mine and b_mine:
456
+ return " (connects your network)"
457
+
458
+ if a_mine:
459
+ from_id, to_id = a_str, b_str
460
+ elif b_mine:
461
+ from_id, to_id = b_str, a_str
462
+ else:
463
+ return ""
464
+
465
+ from_node = nodes.get(from_id, {})
466
+ building = from_node.get("building")
467
+ if building and from_node.get("color") == color:
468
+ tag = "your " + building.lower()
469
+ else:
470
+ tag = "your road"
471
+
472
+ to_label = _node_resource_label(state, to_id)
473
+ port_nodes = state.get("port_nodes", {})
474
+ port = None
475
+ for res_or_any, nids in port_nodes.items():
476
+ if int(to_id) in nids:
477
+ port = "3:1" if res_or_any == "ANY" else f"2:1 {res_or_any}"
478
+ break
479
+ if port and to_label:
480
+ return f" from {from_id} ({tag}) → {to_id}: {to_label} [port {port}]"
481
+ if to_label:
482
+ return f" from {from_id} ({tag}) → {to_id}: {to_label}"
483
+ return f" from {from_id} ({tag}) → {to_id}"
484
+
485
+
486
+ def _node_annotation(node_val, state: dict) -> str:
487
+ """Annotate a BUILD_TIDE_POOL node with resources and port info."""
488
+ if state is None:
489
+ return ""
490
+ nid = str(node_val)
491
+ label = _node_resource_label(state, nid)
492
+ port_nodes = state.get("port_nodes", {})
493
+ port = None
494
+ for res_or_any, nids in port_nodes.items():
495
+ if int(nid) in nids:
496
+ port = "3:1" if res_or_any == "ANY" else f"2:1 {res_or_any}"
497
+ break
498
+ parts = []
499
+ if label:
500
+ parts.append(label)
501
+ if port:
502
+ parts.append(f"port {port}")
503
+ if parts:
504
+ return " " + ", ".join(parts)
505
+ return ""
506
+
507
+
508
+ def _print_actions(actions: list, my_color: str | None = None, state: dict | None = None):
346
509
  _section("Available Actions")
347
510
 
348
511
  my_actions = []
@@ -365,12 +528,25 @@ def _print_actions(actions: list, my_color: str | None = None):
365
528
  val = a[2] if isinstance(a, list) and len(a) > 2 else None
366
529
  grouped[atype].append(val)
367
530
 
531
+ annotated_types = {"BUILD_CURRENT", "BUILD_TIDE_POOL", "BUILD_REEF"}
532
+
368
533
  for atype, values in grouped.items():
369
534
  hint = _ACTION_HINTS.get(atype)
370
535
  if all(v is None for v in values):
371
536
  print(f" {atype}")
372
537
  if hint:
373
538
  print(f" ({hint})")
539
+ elif state and my_color and atype in annotated_types:
540
+ print(f" {atype} ({len(values)} options):")
541
+ if hint:
542
+ print(f" ({hint})")
543
+ for v in values:
544
+ f = json.dumps(v, separators=(",", ":"))
545
+ if atype == "BUILD_CURRENT":
546
+ ann = _edge_annotation(v, state, my_color)
547
+ else:
548
+ ann = _node_annotation(v, state)
549
+ print(f" {f}{ann}")
374
550
  else:
375
551
  formatted = [json.dumps(v, separators=(",", ":")) for v in values]
376
552
  if hint:
@@ -426,6 +602,94 @@ def _print_history(records: list, since: int = 0):
426
602
  print(f" {r}")
427
603
 
428
604
 
605
+ def _format_live_action(color, action, val, state=None, pre_resources=None):
606
+ """Format a game action as a human-readable live update line."""
607
+ ts = time.strftime("%H:%M:%S")
608
+
609
+ if action == "ROLL_THE_SHELLS":
610
+ if isinstance(val, list) and len(val) == 2:
611
+ line = f" [{ts}] {color} rolled {val[0]}+{val[1]}={sum(val)}"
612
+ elif val is not None:
613
+ line = f" [{ts}] {color} rolled {val}"
614
+ else:
615
+ line = f" [{ts}] {color} rolled the shells"
616
+ if pre_resources and state:
617
+ post = _all_player_resources(state)
618
+ parts = []
619
+ for c in state.get("colors", []):
620
+ gains = []
621
+ for res in RESOURCES:
622
+ diff = post.get(c, {}).get(res, 0) - pre_resources.get(c, {}).get(res, 0)
623
+ if diff > 0:
624
+ gains.append(f"+{diff} {res}")
625
+ elif diff < 0:
626
+ gains.append(f"{diff} {res}")
627
+ if gains:
628
+ parts.append(f" {c}: {', '.join(gains)}")
629
+ if parts:
630
+ line += "\n" + "\n".join(parts)
631
+ else:
632
+ line += " — no resources produced"
633
+ return line
634
+
635
+ if action == "MOVE_THE_KRAKEN":
636
+ tile = val
637
+ victim = None
638
+ if isinstance(val, list) and len(val) >= 2:
639
+ tile = val[0]
640
+ victim = val[1]
641
+ if victim:
642
+ return f" [{ts}] {color} moved the Kraken to {tile}, stealing from {victim}"
643
+ return f" [{ts}] {color} moved the Kraken to {tile}"
644
+
645
+ if action == "BUILD_TIDE_POOL":
646
+ return f" [{ts}] {color} built a settlement" + (f" on node {val}" if val is not None else "")
647
+
648
+ if action == "BUILD_REEF":
649
+ return f" [{ts}] {color} upgraded to a city" + (f" on node {val}" if val is not None else "")
650
+
651
+ if action == "BUILD_CURRENT":
652
+ return f" [{ts}] {color} built a road" + (f" on edge {val}" if val is not None else "")
653
+
654
+ if action == "BUY_TREASURE_MAP":
655
+ return f" [{ts}] {color} bought a development card"
656
+
657
+ if action == "SUMMON_LOBSTER_GUARD":
658
+ return f" [{ts}] {color} played a Knight"
659
+
660
+ if action == "RELEASE_CATCH":
661
+ return f" [{ts}] {color} discarded resources"
662
+
663
+ if action == "PLAY_BOUNTIFUL_HARVEST":
664
+ if isinstance(val, list):
665
+ return f" [{ts}] {color} played Year of Plenty: {', '.join(str(v) for v in val)}"
666
+ return f" [{ts}] {color} played Year of Plenty"
667
+
668
+ if action == "PLAY_TIDAL_MONOPOLY":
669
+ return f" [{ts}] {color} played Monopoly" + (f" on {val}" if val else "")
670
+
671
+ if action == "PLAY_CURRENT_BUILDING":
672
+ return f" [{ts}] {color} played Road Building"
673
+
674
+ if action == "OCEAN_TRADE":
675
+ if isinstance(val, list) and len(val) >= 2:
676
+ giving = val[:-1]
677
+ receiving = val[-1]
678
+ give_counts = {}
679
+ for r in giving:
680
+ give_counts[r] = give_counts.get(r, 0) + 1
681
+ give_str = ", ".join(f"{n}x {r}" for r, n in give_counts.items())
682
+ return f" [{ts}] {color} traded {give_str} for 1x {receiving}"
683
+ return f" [{ts}] {color} made a trade"
684
+
685
+ if action == "END_TIDE":
686
+ return f" [{ts}] {color} ended their turn"
687
+
688
+ if val is not None:
689
+ return f" [{ts}] {color}: {action} {json.dumps(val, separators=(',', ':'))}"
690
+ return f" [{ts}] {color}: {action}"
691
+
692
+
429
693
  def _count_turns(state: dict) -> int:
430
694
  """Count ROLL_THE_SHELLS records to get the turn number."""
431
695
  count = 0
@@ -493,24 +757,32 @@ def _print_join(resp: dict):
493
757
  print(f" Started: {'yes' if resp.get('game_started') else 'no'}")
494
758
 
495
759
  _save_session(resp["game_id"], resp["token"], resp["player_color"])
496
- print(f"\n Session saved to {_session_path()}")
497
- print(f" All subsequent clawtan commands will use this session automatically.")
760
+ gid = resp["game_id"]
761
+ color = resp["player_color"]
762
+ print(f"\n Session saved to ~/.clawtan_sessions/{gid}_{color}.json")
763
+ print(f"\n Multi-player setup — pick ONE option:")
764
+ print(f" export CLAWTAN_COLOR={color} # env var per terminal")
765
+ print(f" clawtan --player {color} <command> # flag per command")
766
+ print(f" clawtan --player {color} wait --game {gid} # if same color in multiple games")
498
767
 
499
768
 
500
769
  def cmd_wait(args):
501
- game_id = _env("GAME", args.game)
502
- token = _env("TOKEN", args.token)
503
- color = _env("COLOR", args.color)
770
+ game_id, token, color = _resolve_session(args.game, args.token, args.color)
771
+ _require("GAME", game_id)
772
+ _require("TOKEN", token)
773
+ _require("COLOR", color)
504
774
  poll = args.poll
505
775
  deadline = time.monotonic() + args.timeout
506
776
 
507
777
  # Snapshot current history/chat counts so we can show "what's new"
508
778
  history_len = 0
509
779
  chat_since = 0
780
+ pre_resources = None
510
781
  try:
511
782
  state = _get(f"/game/{game_id}")
512
783
  if state.get("started"):
513
784
  history_len = len(state.get("action_records", []))
785
+ pre_resources = _all_player_resources(state)
514
786
  chat_resp = _get(f"/game/{game_id}/chat")
515
787
  chat_since = len(chat_resp.get("messages", []))
516
788
  except (APIError, Exception):
@@ -518,6 +790,8 @@ def cmd_wait(args):
518
790
 
519
791
  # Poll loop
520
792
  phase_shown = None
793
+ prev_current = None
794
+ live_header_shown = False
521
795
  while True:
522
796
  try:
523
797
  status = _get(f"/game/{game_id}/status", token=token)
@@ -536,7 +810,6 @@ def cmd_wait(args):
536
810
  _header("GAME OVER")
537
811
  winner = status["winning_color"]
538
812
  print(f" Winner: {winner}")
539
- # Fetch final state for scores
540
813
  try:
541
814
  state = _get(f"/game/{game_id}")
542
815
  colors = state.get("colors", [])
@@ -558,10 +831,34 @@ def cmd_wait(args):
558
831
  print(f"Waiting for players ({pj}/{np})...", file=sys.stderr)
559
832
  phase_shown = "lobby"
560
833
  else:
561
- if phase_shown != "turn":
562
- cur = status.get("current_color", "?")
834
+ cur = status.get("current_color", "?")
835
+ if phase_shown != "turn" or cur != prev_current:
563
836
  print(f"Waiting for your turn (current: {cur})...", file=sys.stderr)
564
837
  phase_shown = "turn"
838
+ prev_current = cur
839
+
840
+ # Live action feed: detect and display new game actions
841
+ try:
842
+ live_state = _get(f"/game/{game_id}")
843
+ records = live_state.get("action_records", [])
844
+ new_count = len(records)
845
+ if new_count > history_len:
846
+ if not live_header_shown:
847
+ print("── Game Progress ──", file=sys.stderr)
848
+ live_header_shown = True
849
+ for r in records[history_len:new_count]:
850
+ c, a, v = _unpack_record(r)
851
+ if c and a:
852
+ desc = _format_live_action(
853
+ c, a, v,
854
+ state=live_state,
855
+ pre_resources=pre_resources,
856
+ )
857
+ print(desc, file=sys.stderr)
858
+ history_len = new_count
859
+ pre_resources = _all_player_resources(live_state)
860
+ except (APIError, Exception):
861
+ pass
565
862
 
566
863
  # Our turn!
567
864
  if status.get("your_turn"):
@@ -605,11 +902,11 @@ def cmd_wait(args):
605
902
 
606
903
  actions = state.get("current_playable_actions", [])
607
904
  if actions:
608
- _print_actions(actions, my_color=color)
905
+ _print_actions(actions, my_color=color, state=state)
609
906
 
610
907
  robber = state.get("robber_coordinate")
611
908
  if robber:
612
- print(f"\n Robber: {robber}")
909
+ print(f"\n Kraken: {robber}")
613
910
 
614
911
 
615
912
  def _print_roll_result(state: dict, pre_resources: dict | None):
@@ -652,9 +949,10 @@ def _print_roll_result(state: dict, pre_resources: dict | None):
652
949
 
653
950
 
654
951
  def cmd_act(args):
655
- game_id = _env("GAME", args.game)
656
- token = _env("TOKEN", args.token)
657
- color = _env("COLOR", args.color)
952
+ game_id, token, color = _resolve_session(args.game, args.token, args.color)
953
+ _require("GAME", game_id)
954
+ _require("TOKEN", token)
955
+ _require("COLOR", color)
658
956
 
659
957
  # Snapshot resources before rolling so we can diff afterwards
660
958
  pre_resources = None
@@ -694,7 +992,7 @@ def cmd_act(args):
694
992
  print(f" Current turn: {current} | Prompt: {prompt}", file=sys.stderr)
695
993
  actions = state.get("current_playable_actions", [])
696
994
  if actions:
697
- _print_actions(actions, my_color=color)
995
+ _print_actions(actions, my_color=color, state=state)
698
996
  print(
699
997
  "\n Tip: run 'clawtan wait' to get a full turn briefing with available actions.",
700
998
  file=sys.stderr,
@@ -739,21 +1037,21 @@ def cmd_act(args):
739
1037
  _print_resources(my["resources"])
740
1038
 
741
1039
  if my_actions:
742
- _print_actions(actions, my_color=color)
1040
+ _print_actions(actions, my_color=color, state=state)
743
1041
  else:
744
1042
  print("\n No actions available.")
745
1043
  elif my_actions:
746
1044
  # We have actions even though current_color is someone else
747
1045
  # (e.g. we also need to discard on a 7)
748
1046
  print(f" Prompt: {prompt}")
749
- _print_actions(actions, my_color=color)
1047
+ _print_actions(actions, my_color=color, state=state)
750
1048
  else:
751
1049
  print(f"\n Action done. No more actions available. Run 'clawtan wait' for your next turn or action required.")
752
1050
 
753
1051
 
754
1052
  def cmd_status(args):
755
- game_id = _env("GAME", args.game)
756
- token = _env("TOKEN", args.token, required=False)
1053
+ game_id, token, _ = _resolve_session(args.game, args.token)
1054
+ _require("GAME", game_id)
757
1055
 
758
1056
  status = _get(f"/game/{game_id}/status", token=token)
759
1057
 
@@ -777,7 +1075,8 @@ def cmd_status(args):
777
1075
 
778
1076
 
779
1077
  def cmd_board(args):
780
- game_id = _env("GAME", args.game)
1078
+ game_id, _, _ = _resolve_session(args.game)
1079
+ _require("GAME", game_id)
781
1080
  state = _get(f"/game/{game_id}")
782
1081
 
783
1082
  if not state.get("started") or not state.get("tiles"):
@@ -902,19 +1201,36 @@ def cmd_board(args):
902
1201
  for r in roads:
903
1202
  print(f" Edge {r['id']}: {r['color']}")
904
1203
 
1204
+ # Node adjacency graph for road planning
1205
+ adjacency = defaultdict(list)
1206
+ for e in state.get("edges", []):
1207
+ eid = e.get("id")
1208
+ if isinstance(eid, list) and len(eid) == 2:
1209
+ a, b = str(eid[0]), str(eid[1])
1210
+ adjacency[a].append(b)
1211
+ adjacency[b].append(a)
1212
+
1213
+ if adjacency:
1214
+ _section("Node Graph (edges)")
1215
+ for nid in sorted(adjacency.keys(), key=lambda x: int(x)):
1216
+ neighbors = sorted(adjacency[nid], key=lambda x: int(x))
1217
+ print(f" {nid}: {', '.join(neighbors)}")
1218
+
905
1219
  if robber:
906
- print(f"\n Robber: {robber}")
1220
+ print(f"\n Kraken: {robber}")
907
1221
 
908
1222
 
909
1223
  def cmd_chat(args):
910
- game_id = _env("GAME", args.game)
911
- token = _env("TOKEN", args.token)
1224
+ game_id, token, _ = _resolve_session(args.game, args.token)
1225
+ _require("GAME", game_id)
1226
+ _require("TOKEN", token)
912
1227
  _post(f"/game/{game_id}/chat", {"message": args.message}, token=token)
913
1228
  print("Chat sent.")
914
1229
 
915
1230
 
916
1231
  def cmd_chat_read(args):
917
- game_id = _env("GAME", args.game)
1232
+ game_id, _, _ = _resolve_session(args.game)
1233
+ _require("GAME", game_id)
918
1234
  resp = _get(f"/game/{game_id}/chat?since={args.since}")
919
1235
  msgs = resp.get("messages", [])
920
1236
  if msgs:
@@ -939,6 +1255,11 @@ def main():
939
1255
  ),
940
1256
  formatter_class=argparse.RawDescriptionHelpFormatter,
941
1257
  )
1258
+ parser.add_argument(
1259
+ "--player",
1260
+ metavar="COLOR",
1261
+ help="Player color — selects the per-player session file (for multi-player on one machine)",
1262
+ )
942
1263
  sub = parser.add_subparsers(dest="command", required=True)
943
1264
 
944
1265
  # -- create --------------------------------------------------------
@@ -1014,7 +1335,7 @@ def main():
1014
1335
  " BUILD_CURRENT <edge> Build road, e.g. '[3,7]'\n"
1015
1336
  " BUY_TREASURE_MAP Buy dev card\n"
1016
1337
  " SUMMON_LOBSTER_GUARD Play knight card\n"
1017
- " MOVE_THE_KRAKEN <val> Move robber, e.g. '[[0,1,-1],\"BLUE\",null]'\n"
1338
+ " MOVE_THE_KRAKEN <val> Move Kraken, e.g. '[[0,1,-1],\"BLUE\",null]'\n"
1018
1339
  " RELEASE_CATCH Discard half your cards (server selects randomly)\n"
1019
1340
  " PLAY_BOUNTIFUL_HARVEST <r> Year of Plenty, e.g. '[\"DRIFTWOOD\",\"CORAL\"]'\n"
1020
1341
  " PLAY_TIDAL_MONOPOLY <res> Monopoly, e.g. SHRIMP\n"
@@ -1058,7 +1379,7 @@ def main():
1058
1379
  help="Show board layout, buildings, and roads",
1059
1380
  description=(
1060
1381
  "Display the board: tiles with resources/numbers, ports,\n"
1061
- "buildings, roads, and robber location.\n"
1382
+ "buildings, roads, and Kraken location.\n"
1062
1383
  "Tile layout is static after game start -- call once and remember it."
1063
1384
  ),
1064
1385
  )
@@ -1088,6 +1409,8 @@ def main():
1088
1409
 
1089
1410
  # -- Parse and run -------------------------------------------------
1090
1411
  args = parser.parse_args()
1412
+ if args.player:
1413
+ os.environ["CLAWTAN_COLOR"] = args.player
1091
1414
  try:
1092
1415
  args.func(args)
1093
1416
  except APIError as e:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawtan",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "CLI for AI agents playing Settlers of Clawtan -- a lobster-themed Catan board game",
5
5
  "bin": {
6
6
  "clawtan": "./bin/clawtan.js"