clawtan 0.1.13 → 0.2.1

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 +197 -64
  2. package/package.json +1 -1
package/clawtan/cli.py CHANGED
@@ -3,22 +3,35 @@
3
3
  clawtan -- CLI for AI agents playing Settlers of Clawtan.
4
4
 
5
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
6
+ by LLM agents. Session credentials are saved automatically on join.
12
7
 
13
8
  Typical agent flow:
14
- clawtan quick-join --name "LobsterBot"
15
- export CLAWTAN_GAME=... CLAWTAN_TOKEN=... CLAWTAN_COLOR=...
9
+ clawtan quick-join --name "LobsterBot" # session saved to ~/.clawtan_session
16
10
  clawtan board # once, to learn the map
17
11
  clawtan wait # blocks until your turn
18
12
  clawtan act ROLL_THE_SHELLS
19
13
  clawtan act BUILD_TIDE_POOL 42
20
14
  clawtan act END_TIDE
21
15
  clawtan wait # next turn...
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
+
29
+ Session lookup order (per field):
30
+ 1. CLI flags (--game, --token, --color, --player)
31
+ 2. Environment variables (CLAWTAN_GAME, CLAWTAN_TOKEN, CLAWTAN_COLOR)
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.
22
35
  """
23
36
 
24
37
  import argparse
@@ -125,13 +138,102 @@ def _get(path, token=None):
125
138
 
126
139
 
127
140
  # ---------------------------------------------------------------------------
128
- # Environment variable helpers
141
+ # Session file helpers
129
142
  # ---------------------------------------------------------------------------
130
- def _env(name: str, arg_val=None, required=True):
131
- val = arg_val or os.environ.get(f"CLAWTAN_{name}")
132
- if required and not val:
143
+ _SESSIONS_DIR = os.path.expanduser("~/.clawtan_sessions")
144
+
145
+
146
+ def _save_session(game_id: str, token: str, color: str):
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")
150
+ try:
151
+ with open(path, "w") as f:
152
+ json.dump(data, f)
153
+ except OSError as e:
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
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]
200
+
201
+ default = os.path.expanduser("~/.clawtan_session")
202
+ try:
203
+ with open(default) as f:
204
+ return json.load(f)
205
+ except (OSError, json.JSONDecodeError):
206
+ return {}
207
+
208
+
209
+ # ---------------------------------------------------------------------------
210
+ # Session resolution
211
+ # ---------------------------------------------------------------------------
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."""
233
+ if not val:
133
234
  print(
134
- f"ERROR: Missing {name}. Pass --{name.lower()} or set CLAWTAN_{name}",
235
+ f"ERROR: Missing {name}. Pass --{name.lower()}, set CLAWTAN_{name},"
236
+ f" or run 'clawtan quick-join' to create a session.",
135
237
  file=sys.stderr,
136
238
  )
137
239
  sys.exit(1)
@@ -290,10 +392,8 @@ def _print_opponents(opponents: list):
290
392
 
291
393
  _ACTION_HINTS = {
292
394
  "RELEASE_CATCH": (
293
- "Discard cards. Run with no value to discard randomly:\n"
294
- " CLI: clawtan act RELEASE_CATCH\n"
295
- " Or pick specific cards (freqdeck=[DRIFTWOOD,CORAL,SHRIMP,KELP,PEARL]):\n"
296
- " CLI: clawtan act RELEASE_CATCH '[1,0,0,1,0]'"
395
+ "Discard half your cards (server selects randomly).\n"
396
+ " CLI: clawtan act RELEASE_CATCH"
297
397
  ),
298
398
  "MOVE_THE_KRAKEN": (
299
399
  "Move robber: value = [coordinate, victim_color_or_null, null].\n"
@@ -360,17 +460,34 @@ def _print_actions(actions: list, my_color: str | None = None):
360
460
  print(f"\n (other players still need to act: {', '.join(sorted(other_colors))})")
361
461
 
362
462
 
463
+ def _unpack_record(r):
464
+ """Unpack an action record into (color, action_type, value).
465
+
466
+ Records may be nested [[color, type, value], result] or flat [color, type, value].
467
+ """
468
+ if isinstance(r, list) and len(r) >= 2 and isinstance(r[0], list):
469
+ action = r[0]
470
+ color = action[0] if len(action) > 0 else None
471
+ atype = action[1] if len(action) > 1 else None
472
+ val = action[2] if len(action) > 2 else None
473
+ return color, atype, val
474
+ if isinstance(r, list) and len(r) >= 2:
475
+ color = r[0]
476
+ atype = r[1]
477
+ val = r[2] if len(r) > 2 else None
478
+ return color, atype, val
479
+ return None, None, None
480
+
481
+
363
482
  def _print_history(records: list, since: int = 0):
364
483
  recent = records[since:]
365
484
  if not recent:
366
485
  return
367
486
  _section(f"Recent Actions ({len(recent)} moves)")
368
487
  for r in recent:
369
- if isinstance(r, list) and len(r) >= 2:
370
- color = r[0]
371
- action = r[1]
372
- val = r[2] if len(r) > 2 and r[2] is not None else ""
373
- if val != "":
488
+ color, action, val = _unpack_record(r)
489
+ if color and action:
490
+ if val is not None:
374
491
  print(f" {color}: {action} {json.dumps(val, separators=(',', ':'))}")
375
492
  else:
376
493
  print(f" {color}: {action}")
@@ -378,6 +495,25 @@ def _print_history(records: list, since: int = 0):
378
495
  print(f" {r}")
379
496
 
380
497
 
498
+ def _count_turns(state: dict) -> int:
499
+ """Count ROLL_THE_SHELLS records to get the turn number."""
500
+ count = 0
501
+ for r in state.get("action_records", []):
502
+ _, atype, _ = _unpack_record(r)
503
+ if atype == "ROLL_THE_SHELLS":
504
+ count += 1
505
+ return count
506
+
507
+
508
+ def _who_rolled_last(state: dict) -> str | None:
509
+ """Return the color of the player who made the most recent roll."""
510
+ for r in reversed(state.get("action_records", [])):
511
+ color, atype, _ = _unpack_record(r)
512
+ if atype == "ROLL_THE_SHELLS":
513
+ return color
514
+ return None
515
+
516
+
381
517
  def _print_chat(messages: list, label: str = "Chat"):
382
518
  if not messages:
383
519
  return
@@ -424,16 +560,22 @@ def _print_join(resp: dict):
424
560
  print(f" Seat: {resp['seat_index']}")
425
561
  print(f" Players: {resp['players_joined']}")
426
562
  print(f" Started: {'yes' if resp.get('game_started') else 'no'}")
427
- print(f"\nSet your session:")
428
- print(f" export CLAWTAN_GAME={resp['game_id']}")
429
- print(f" export CLAWTAN_TOKEN={resp['token']}")
430
- print(f" export CLAWTAN_COLOR={resp['player_color']}")
563
+
564
+ _save_session(resp["game_id"], resp["token"], resp["player_color"])
565
+ gid = resp["game_id"]
566
+ color = resp["player_color"]
567
+ print(f"\n Session saved to ~/.clawtan_sessions/{gid}_{color}.json")
568
+ print(f"\n Multi-player setup — pick ONE option:")
569
+ print(f" export CLAWTAN_COLOR={color} # env var per terminal")
570
+ print(f" clawtan --player {color} <command> # flag per command")
571
+ print(f" clawtan --player {color} wait --game {gid} # if same color in multiple games")
431
572
 
432
573
 
433
574
  def cmd_wait(args):
434
- game_id = _env("GAME", args.game)
435
- token = _env("TOKEN", args.token)
436
- color = _env("COLOR", args.color)
575
+ game_id, token, color = _resolve_session(args.game, args.token, args.color)
576
+ _require("GAME", game_id)
577
+ _require("TOKEN", token)
578
+ _require("COLOR", color)
437
579
  poll = args.poll
438
580
  deadline = time.monotonic() + args.timeout
439
581
 
@@ -510,7 +652,7 @@ def cmd_wait(args):
510
652
  state = _get(f"/game/{game_id}")
511
653
 
512
654
  prompt = state.get("current_prompt", "?")
513
- turns = state.get("num_turns", "?")
655
+ turns = status.get("num_turns") or state.get("num_turns") or _count_turns(state)
514
656
 
515
657
  _header("YOUR TURN")
516
658
  print(f" Game: {game_id}")
@@ -551,8 +693,9 @@ def _print_roll_result(state: dict, pre_resources: dict | None):
551
693
  records = state.get("action_records", [])
552
694
  roll_val = None
553
695
  for r in reversed(records):
554
- if isinstance(r, list) and len(r) >= 2 and r[1] == "ROLL_THE_SHELLS":
555
- roll_val = r[2] if len(r) > 2 else None
696
+ color, atype, val = _unpack_record(r)
697
+ if atype == "ROLL_THE_SHELLS":
698
+ roll_val = val
556
699
  break
557
700
 
558
701
  if roll_val is not None:
@@ -584,9 +727,10 @@ def _print_roll_result(state: dict, pre_resources: dict | None):
584
727
 
585
728
 
586
729
  def cmd_act(args):
587
- game_id = _env("GAME", args.game)
588
- token = _env("TOKEN", args.token)
589
- color = _env("COLOR", args.color)
730
+ game_id, token, color = _resolve_session(args.game, args.token, args.color)
731
+ _require("GAME", game_id)
732
+ _require("TOKEN", token)
733
+ _require("COLOR", color)
590
734
 
591
735
  # Snapshot resources before rolling so we can diff afterwards
592
736
  pre_resources = None
@@ -679,34 +823,13 @@ def cmd_act(args):
679
823
  # (e.g. we also need to discard on a 7)
680
824
  print(f" Prompt: {prompt}")
681
825
  _print_actions(actions, my_color=color)
682
- print(
683
- f"\n Note: {current_color} is also acting (e.g. discarding)."
684
- f" Your turn will continue after -- run 'clawtan wait'.",
685
- )
686
- elif prompt in ("RELEASE_CATCH", "MOVE_THE_KRAKEN", "DISCARD"):
687
- # Discard/robber phase -- other players are acting but our turn resumes after
688
- _section("Waiting on Other Players")
689
- # Figure out which players need to act
690
- other_colors = set()
691
- for a in actions:
692
- if isinstance(a, list) and len(a) > 1 and a[0] and a[0] != color:
693
- other_colors.add(a[0])
694
- if other_colors:
695
- print(f" {', '.join(sorted(other_colors))} must {prompt.lower().replace('_', ' ')} first.")
696
- else:
697
- print(f" Current prompt: {prompt} (waiting on {current_color})")
698
- print(
699
- f"\n YOUR TURN IS NOT OVER. After they finish, you will continue"
700
- f" (e.g. move the Kraken, then play your turn)."
701
- f"\n Run 'clawtan wait' to resume."
702
- )
703
826
  else:
704
- print(f"\n Turn passed to {current_color}. Run 'clawtan wait' for your next turn.")
827
+ print(f"\n Action done. No more actions available. Run 'clawtan wait' for your next turn or action required.")
705
828
 
706
829
 
707
830
  def cmd_status(args):
708
- game_id = _env("GAME", args.game)
709
- token = _env("TOKEN", args.token, required=False)
831
+ game_id, token, _ = _resolve_session(args.game, args.token)
832
+ _require("GAME", game_id)
710
833
 
711
834
  status = _get(f"/game/{game_id}/status", token=token)
712
835
 
@@ -730,7 +853,8 @@ def cmd_status(args):
730
853
 
731
854
 
732
855
  def cmd_board(args):
733
- game_id = _env("GAME", args.game)
856
+ game_id, _, _ = _resolve_session(args.game)
857
+ _require("GAME", game_id)
734
858
  state = _get(f"/game/{game_id}")
735
859
 
736
860
  if not state.get("started") or not state.get("tiles"):
@@ -860,14 +984,16 @@ def cmd_board(args):
860
984
 
861
985
 
862
986
  def cmd_chat(args):
863
- game_id = _env("GAME", args.game)
864
- token = _env("TOKEN", args.token)
987
+ game_id, token, _ = _resolve_session(args.game, args.token)
988
+ _require("GAME", game_id)
989
+ _require("TOKEN", token)
865
990
  _post(f"/game/{game_id}/chat", {"message": args.message}, token=token)
866
991
  print("Chat sent.")
867
992
 
868
993
 
869
994
  def cmd_chat_read(args):
870
- game_id = _env("GAME", args.game)
995
+ game_id, _, _ = _resolve_session(args.game)
996
+ _require("GAME", game_id)
871
997
  resp = _get(f"/game/{game_id}/chat?since={args.since}")
872
998
  msgs = resp.get("messages", [])
873
999
  if msgs:
@@ -892,6 +1018,11 @@ def main():
892
1018
  ),
893
1019
  formatter_class=argparse.RawDescriptionHelpFormatter,
894
1020
  )
1021
+ parser.add_argument(
1022
+ "--player",
1023
+ metavar="COLOR",
1024
+ help="Player color — selects the per-player session file (for multi-player on one machine)",
1025
+ )
895
1026
  sub = parser.add_subparsers(dest="command", required=True)
896
1027
 
897
1028
  # -- create --------------------------------------------------------
@@ -968,7 +1099,7 @@ def main():
968
1099
  " BUY_TREASURE_MAP Buy dev card\n"
969
1100
  " SUMMON_LOBSTER_GUARD Play knight card\n"
970
1101
  " MOVE_THE_KRAKEN <val> Move robber, e.g. '[[0,1,-1],\"BLUE\",null]'\n"
971
- " RELEASE_CATCH [freqdeck] Discard cards (no value = random), e.g. '[1,0,0,1,0]'\n"
1102
+ " RELEASE_CATCH Discard half your cards (server selects randomly)\n"
972
1103
  " PLAY_BOUNTIFUL_HARVEST <r> Year of Plenty, e.g. '[\"DRIFTWOOD\",\"CORAL\"]'\n"
973
1104
  " PLAY_TIDAL_MONOPOLY <res> Monopoly, e.g. SHRIMP\n"
974
1105
  " PLAY_CURRENT_BUILDING Road Building\n"
@@ -1041,6 +1172,8 @@ def main():
1041
1172
 
1042
1173
  # -- Parse and run -------------------------------------------------
1043
1174
  args = parser.parse_args()
1175
+ if args.player:
1176
+ os.environ["CLAWTAN_COLOR"] = args.player
1044
1177
  try:
1045
1178
  args.func(args)
1046
1179
  except APIError as e:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawtan",
3
- "version": "0.1.13",
3
+ "version": "0.2.1",
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"