clawtan 0.2.4 → 0.2.6

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 +251 -25
  2. package/package.json +1 -1
package/clawtan/cli.py CHANGED
@@ -7,6 +7,7 @@ by LLM agents. Session credentials are saved automatically on join.
7
7
 
8
8
  Typical agent flow:
9
9
  clawtan quick-join --name "LobsterBot" # session saved to ~/.clawtan_session
10
+ clawtan whoami # verify correct session
10
11
  clawtan board # once, to learn the map
11
12
  clawtan wait # blocks until your turn
12
13
  clawtan act ROLL_THE_SHELLS
@@ -14,6 +15,10 @@ Typical agent flow:
14
15
  clawtan act END_TIDE
15
16
  clawtan wait # next turn...
16
17
 
18
+ Between games:
19
+ clawtan clear-session # remove default session
20
+ clawtan clear-session --all # remove ALL saved sessions
21
+
17
22
  Multi-player on one machine (each terminal):
18
23
  clawtan join <GAME_ID> # join, note your assigned color
19
24
  export CLAWTAN_COLOR=<COLOR> # lock this terminal to your player
@@ -152,7 +157,22 @@ def _save_session(game_id: str, token: str, color: str):
152
157
  json.dump(data, f)
153
158
  except OSError as e:
154
159
  print(f"Warning: could not save session to {path}: {e}", file=sys.stderr)
160
+
161
+ # Warn if the default session pointed at a different game (stale session)
155
162
  default = os.environ.get("CLAWTAN_SESSION_FILE") or os.path.expanduser("~/.clawtan_session")
163
+ try:
164
+ with open(default) as f:
165
+ prev = json.load(f)
166
+ prev_game = prev.get("GAME")
167
+ if prev_game and prev_game != game_id:
168
+ print(
169
+ f" Note: overwriting previous session (game {prev_game})."
170
+ f" Run 'clawtan clear-session --game {prev_game}' to clean up old files.",
171
+ file=sys.stderr,
172
+ )
173
+ except (OSError, json.JSONDecodeError):
174
+ pass
175
+
156
176
  try:
157
177
  with open(default, "w") as f:
158
178
  json.dump(data, f)
@@ -179,24 +199,35 @@ def _find_session(game_hint: str | None = None, color_hint: str | None = None) -
179
199
  pass
180
200
 
181
201
  if (game_hint or color_hint) and os.path.isdir(_SESSIONS_DIR):
182
- matches = []
202
+ matches = [] # list of (mtime, data) tuples
183
203
  for fname in os.listdir(_SESSIONS_DIR):
184
204
  if not fname.endswith(".json"):
185
205
  continue
206
+ fpath = os.path.join(_SESSIONS_DIR, fname)
186
207
  try:
187
- with open(os.path.join(_SESSIONS_DIR, fname)) as f:
208
+ with open(fpath) as f:
188
209
  data = json.load(f)
189
210
  if game_hint and data.get("GAME") != game_hint:
190
211
  continue
191
212
  if color_hint and data.get("COLOR") != color_hint:
192
213
  continue
193
- matches.append(data)
214
+ mtime = os.path.getmtime(fpath)
215
+ matches.append((mtime, data))
194
216
  except (OSError, json.JSONDecodeError):
195
217
  continue
196
- if len(matches) == 1:
197
- return matches[0]
198
218
  if matches:
199
- return matches[0]
219
+ # Sort by mtime descending so most recently written session wins
220
+ matches.sort(key=lambda m: m[0], reverse=True)
221
+ if len(matches) > 1:
222
+ chosen = matches[0][1]
223
+ print(
224
+ f"Warning: {len(matches)} session files match hints"
225
+ f" (game={game_hint}, color={color_hint})."
226
+ f" Using most recent: {chosen.get('GAME')}_{chosen.get('COLOR')}."
227
+ f" Pass --game and --color to be explicit.",
228
+ file=sys.stderr,
229
+ )
230
+ return matches[0][1]
200
231
 
201
232
  default = os.path.expanduser("~/.clawtan_session")
202
233
  try:
@@ -240,6 +271,89 @@ def _require(name: str, val):
240
271
  return val
241
272
 
242
273
 
274
+ def cmd_whoami(args):
275
+ """Show the currently resolved session (game, color, token)."""
276
+ game, token, color = _resolve_session(
277
+ getattr(args, "game", None),
278
+ getattr(args, "token", None),
279
+ getattr(args, "color", None),
280
+ )
281
+ _header("CURRENT SESSION")
282
+ print(f" Game: {game or '(not set)'}")
283
+ print(f" Color: {color or '(not set)'}")
284
+ print(f" Token: {token[:12] + '...' if token and len(token) > 12 else token or '(not set)'}")
285
+
286
+ # Show where each value came from
287
+ sources = []
288
+ if getattr(args, "game", None) or getattr(args, "color", None):
289
+ sources.append("CLI flags")
290
+ for var in ("CLAWTAN_GAME", "CLAWTAN_TOKEN", "CLAWTAN_COLOR"):
291
+ if os.environ.get(var):
292
+ sources.append(f"env ${var}")
293
+ if not sources:
294
+ sources.append("session file")
295
+ print(f" Source: {', '.join(sources)}")
296
+
297
+ if not (game and token and color):
298
+ print(
299
+ "\n Session incomplete. Run 'clawtan quick-join' or pass --game/--token/--color.",
300
+ file=sys.stderr,
301
+ )
302
+ sys.exit(1)
303
+
304
+
305
+ def cmd_clear_session(args):
306
+ """Remove saved session files."""
307
+ removed = 0
308
+
309
+ if args.all:
310
+ # Remove all session files and the default
311
+ if os.path.isdir(_SESSIONS_DIR):
312
+ for fname in os.listdir(_SESSIONS_DIR):
313
+ if fname.endswith(".json"):
314
+ os.remove(os.path.join(_SESSIONS_DIR, fname))
315
+ removed += 1
316
+ default = os.path.expanduser("~/.clawtan_session")
317
+ if os.path.exists(default):
318
+ os.remove(default)
319
+ removed += 1
320
+ print(f"Cleared {removed} session file(s).")
321
+ return
322
+
323
+ # Clear a specific game, or just the default session
324
+ game_hint = args.game
325
+ color_hint = args.color
326
+
327
+ if game_hint or color_hint:
328
+ if os.path.isdir(_SESSIONS_DIR):
329
+ for fname in os.listdir(_SESSIONS_DIR):
330
+ if not fname.endswith(".json"):
331
+ continue
332
+ fpath = os.path.join(_SESSIONS_DIR, fname)
333
+ try:
334
+ with open(fpath) as f:
335
+ data = json.load(f)
336
+ if game_hint and data.get("GAME") != game_hint:
337
+ continue
338
+ if color_hint and data.get("COLOR") != color_hint:
339
+ continue
340
+ os.remove(fpath)
341
+ removed += 1
342
+ except (OSError, json.JSONDecodeError):
343
+ continue
344
+ else:
345
+ # No hints — clear the default session file
346
+ default = os.path.expanduser("~/.clawtan_session")
347
+ if os.path.exists(default):
348
+ os.remove(default)
349
+ removed += 1
350
+
351
+ if removed:
352
+ print(f"Cleared {removed} session file(s).")
353
+ else:
354
+ print("No matching session files found.")
355
+
356
+
243
357
  # ---------------------------------------------------------------------------
244
358
  # State extraction (operates on a full game-state dict)
245
359
  # ---------------------------------------------------------------------------
@@ -391,7 +505,11 @@ def _print_opponents(opponents: list):
391
505
 
392
506
 
393
507
  def _format_trade_tuple(val: list) -> str:
394
- """Decode a 10-int trade tuple into 'give X for Y' text."""
508
+ """Decode trade resources (first 10 elements) into 'give X for Y' text.
509
+
510
+ Works for 10-element (OFFER_TRADE) and 11-element (current_trade,
511
+ ACCEPT/REJECT/CONFIRM) tuples — extra elements are ignored.
512
+ """
395
513
  giving = {RESOURCES[i]: val[i] for i in range(5) if val[i]}
396
514
  wanting = {RESOURCES[i]: val[i + 5] for i in range(5) if val[i + 5]}
397
515
  give_str = ", ".join(f"{n}x {r}" for r, n in giving.items()) or "nothing"
@@ -424,16 +542,20 @@ _ACTION_HINTS = {
424
542
  " CLI: clawtan act OFFER_TRADE '[0,0,0,1,0,0,1,0,0,0]' # give 1 KELP, want 1 CORAL"
425
543
  ),
426
544
  "ACCEPT_TRADE": (
427
- "Accept a trade offer. Value = the 10-int trade tuple (echoed from the offer).\n"
428
- " CLI: clawtan act ACCEPT_TRADE '[0,0,0,1,0,0,1,0,0,0]'"
545
+ "Accept a trade offer. Value = 11-element current_trade tuple\n"
546
+ " (10 resource ints + offerer's turn index, echoed from the offer).\n"
547
+ " Just use the value shown in your available actions.\n"
548
+ " CLI: clawtan act ACCEPT_TRADE '[0,0,0,1,0,0,1,0,0,0,0]'"
429
549
  ),
430
550
  "REJECT_TRADE": (
431
- "Reject a trade offer. Value = the 10-int trade tuple (echoed from the offer).\n"
432
- " CLI: clawtan act REJECT_TRADE '[0,0,0,1,0,0,1,0,0,0]'"
551
+ "Reject a trade offer. Value = 11-element current_trade tuple\n"
552
+ " (10 resource ints + offerer's turn index, echoed from the offer).\n"
553
+ " Just use the value shown in your available actions.\n"
554
+ " CLI: clawtan act REJECT_TRADE '[0,0,0,1,0,0,1,0,0,0,0]'"
433
555
  ),
434
556
  "CONFIRM_TRADE": (
435
557
  "Confirm trade with a specific acceptee. Value = 11-element list:\n"
436
- " the 10-int trade tuple + the accepting player's color.\n"
558
+ " the 10 resource ints + the accepting player's color.\n"
437
559
  " CLI: clawtan act CONFIRM_TRADE '[0,0,0,1,0,0,1,0,0,0,\"BLUE\"]'"
438
560
  ),
439
561
  "CANCEL_TRADE": (
@@ -599,6 +721,37 @@ def _print_actions(actions: list, my_color: str | None = None, state: dict | Non
599
721
  print(f"\n (other players still need to act: {', '.join(sorted(other_colors))})")
600
722
 
601
723
 
724
+ def _print_trade_context(state: dict, my_color: str):
725
+ """Show the active trade offer and who has accepted so far."""
726
+ trade = state.get("current_trade")
727
+ if not trade or not isinstance(trade, list) or len(trade) < 10:
728
+ return
729
+
730
+ prompt = state.get("current_prompt", "")
731
+ colors = state.get("colors", [])
732
+ offerer_idx = trade[10] if len(trade) > 10 else None
733
+ offerer = colors[offerer_idx] if offerer_idx is not None and offerer_idx < len(colors) else "?"
734
+
735
+ _section("Active Trade")
736
+ print(f" Offered by: {offerer}")
737
+ print(f" Trade: {_format_trade_tuple(trade)}")
738
+
739
+ if prompt == "DECIDE_TRADE":
740
+ if offerer == my_color:
741
+ print(" Waiting for other players to accept or reject.")
742
+ else:
743
+ print(" You must ACCEPT_TRADE or REJECT_TRADE.")
744
+ elif prompt == "DECIDE_ACCEPTEES":
745
+ acceptees = state.get("acceptees", [])
746
+ accepted = [colors[i] for i, a in enumerate(acceptees) if a and i < len(colors)]
747
+ if accepted:
748
+ print(f" Accepted by: {', '.join(accepted)}")
749
+ else:
750
+ print(" No one accepted.")
751
+ if offerer == my_color:
752
+ print(" You may CONFIRM_TRADE with an acceptee or CANCEL_TRADE.")
753
+
754
+
602
755
  def _unpack_record(r):
603
756
  """Unpack an action record into (color, action_type, value).
604
757
 
@@ -709,12 +862,12 @@ def _format_live_action(color, action, val, state=None, pre_resources=None):
709
862
  return f" [{ts}] {color} offered a trade"
710
863
 
711
864
  if action == "ACCEPT_TRADE":
712
- if isinstance(val, list) and len(val) == 10:
865
+ if isinstance(val, list) and len(val) >= 10:
713
866
  return f" [{ts}] {color} accepted trade: {_format_trade_tuple(val)}"
714
867
  return f" [{ts}] {color} accepted a trade"
715
868
 
716
869
  if action == "REJECT_TRADE":
717
- if isinstance(val, list) and len(val) == 10:
870
+ if isinstance(val, list) and len(val) >= 10:
718
871
  return f" [{ts}] {color} rejected trade: {_format_trade_tuple(val)}"
719
872
  return f" [{ts}] {color} rejected a trade"
720
873
 
@@ -865,7 +1018,9 @@ def cmd_wait(args):
865
1018
  if status.get("winning_color"):
866
1019
  _header("GAME OVER")
867
1020
  winner = status["winning_color"]
1021
+ result = "won" if winner == color else "lost"
868
1022
  print(f" Winner: {winner}")
1023
+ print(f" You ({color}): {result}")
869
1024
  try:
870
1025
  state = _get(f"/game/{game_id}")
871
1026
  colors = state.get("colors", [])
@@ -877,7 +1032,8 @@ def cmd_wait(args):
877
1032
  print(f" {c}: {vp} VP{marker}")
878
1033
  except (APIError, Exception):
879
1034
  pass
880
- return
1035
+ print("\n Game is finished. Do not call 'wait' or 'act' again for this game.")
1036
+ sys.exit(2) # distinct exit code so agents can detect game-over
881
1037
 
882
1038
  # Progress messages (to stderr so they don't pollute the briefing)
883
1039
  if not status.get("started"):
@@ -957,6 +1113,9 @@ def cmd_wait(args):
957
1113
  except (APIError, Exception):
958
1114
  pass
959
1115
 
1116
+ if state.get("is_resolving_trade"):
1117
+ _print_trade_context(state, color)
1118
+
960
1119
  actions = state.get("current_playable_actions", [])
961
1120
  if actions:
962
1121
  _print_actions(actions, my_color=color, state=state)
@@ -965,6 +1124,8 @@ def cmd_wait(args):
965
1124
  if robber:
966
1125
  print(f"\n Kraken: {robber}")
967
1126
 
1127
+ print("\n >>> YOUR TURN: pick an action above and run 'clawtan act <ACTION> [VALUE]'.")
1128
+
968
1129
 
969
1130
  def _print_roll_result(state: dict, pre_resources: dict | None):
970
1131
  """Show dice result and per-player resource gains after a roll."""
@@ -1043,6 +1204,19 @@ def cmd_act(args):
1043
1204
  current = state.get("current_color", "?")
1044
1205
  actions = state.get("current_playable_actions", [])
1045
1206
 
1207
+ # Primary diagnosis: is it even our turn?
1208
+ if current != color:
1209
+ print(
1210
+ f" It is NOT your turn. Current turn: {current} (you are {color}).",
1211
+ file=sys.stderr,
1212
+ )
1213
+ print(
1214
+ " >>> Run 'clawtan wait' to block until it is your turn.",
1215
+ file=sys.stderr,
1216
+ )
1217
+ sys.exit(1)
1218
+
1219
+ # It is our turn but wrong action/value
1046
1220
  available_types = set()
1047
1221
  for a in actions:
1048
1222
  if isinstance(a, list) and len(a) > 1:
@@ -1056,17 +1230,22 @@ def cmd_act(args):
1056
1230
  )
1057
1231
  else:
1058
1232
  print(
1059
- f" '{args.action}' is not available right now.",
1233
+ f" '{args.action}' is not available right now (prompt: {prompt}).",
1060
1234
  file=sys.stderr,
1061
1235
  )
1062
1236
 
1063
1237
  print(f" Current turn: {current} | Prompt: {prompt}", file=sys.stderr)
1064
1238
  if actions:
1065
1239
  _print_actions(actions, my_color=color, state=state)
1066
- print(
1067
- "\n Tip: run 'clawtan wait' to get a full turn briefing with available actions.",
1068
- file=sys.stderr,
1069
- )
1240
+ print(
1241
+ "\n >>> Pick an action above and run 'clawtan act <ACTION> [VALUE]'.",
1242
+ file=sys.stderr,
1243
+ )
1244
+ else:
1245
+ print(
1246
+ "\n >>> No actions available. Run 'clawtan wait' for your next turn.",
1247
+ file=sys.stderr,
1248
+ )
1070
1249
  except Exception:
1071
1250
  print(
1072
1251
  " Run 'clawtan wait' to see your available actions.",
@@ -1082,6 +1261,15 @@ def cmd_act(args):
1082
1261
 
1083
1262
  # Re-fetch state so the agent knows what to do next
1084
1263
  state = _get(f"/game/{game_id}")
1264
+
1265
+ # Check if this action ended the game (e.g. winning build)
1266
+ winner = state.get("winning_color")
1267
+ if winner:
1268
+ result = "won" if winner == color else "lost"
1269
+ print(f"\n GAME OVER — Winner: {winner} (you {result})")
1270
+ print(" Game is finished. Do not call 'wait' or 'act' again for this game.")
1271
+ sys.exit(2)
1272
+
1085
1273
  current_color = state.get("current_color")
1086
1274
 
1087
1275
  # After a roll, show the dice result and resource distribution
@@ -1106,17 +1294,23 @@ def cmd_act(args):
1106
1294
  _section("Resources")
1107
1295
  _print_resources(my["resources"])
1108
1296
 
1297
+ if state.get("is_resolving_trade"):
1298
+ _print_trade_context(state, color)
1299
+
1109
1300
  if my_actions:
1110
1301
  _print_actions(actions, my_color=color, state=state)
1302
+ print("\n >>> YOUR TURN: pick an action above and run 'clawtan act <ACTION> [VALUE]'.")
1111
1303
  else:
1112
1304
  print("\n No actions available.")
1305
+ print("\n >>> Run 'clawtan wait' to block until your next turn.")
1113
1306
  elif my_actions:
1114
- # We have actions even though current_color is someone else
1115
- # (e.g. we also need to discard on a 7)
1116
1307
  print(f" Prompt: {prompt}")
1308
+ if state.get("is_resolving_trade"):
1309
+ _print_trade_context(state, color)
1117
1310
  _print_actions(actions, my_color=color, state=state)
1311
+ print("\n >>> ACTION REQUIRED: pick an action above and run 'clawtan act <ACTION> [VALUE]'.")
1118
1312
  else:
1119
- print(f"\n Action done. No more actions available. Run 'clawtan wait' for your next turn or action required.")
1313
+ print("\n >>> Turn complete. Run 'clawtan wait' to block until your next turn.")
1120
1314
 
1121
1315
 
1122
1316
  def cmd_status(args):
@@ -1412,8 +1606,8 @@ def main():
1412
1606
  " PLAY_CURRENT_BUILDING Road Building\n"
1413
1607
  " OFFER_TRADE <val> Player trade: 10-element list [give5, want5]\n"
1414
1608
  " e.g. '[0,0,0,1,0,0,1,0,0,0]' = give 1 KELP, want 1 CORAL\n"
1415
- " ACCEPT_TRADE <val> Accept a trade offer (echo the 10-int tuple)\n"
1416
- " REJECT_TRADE <val> Reject a trade offer (echo the 10-int tuple)\n"
1609
+ " ACCEPT_TRADE <val> Accept trade (echo the 11-element value from actions)\n"
1610
+ " REJECT_TRADE <val> Reject trade (echo the 11-element value from actions)\n"
1417
1611
  " CONFIRM_TRADE <val> Confirm with acceptee: 10 ints + color, e.g.\n"
1418
1612
  " '[0,0,0,1,0,0,1,0,0,0,\"BLUE\"]'\n"
1419
1613
  " CANCEL_TRADE Cancel your pending trade offer\n"
@@ -1484,6 +1678,38 @@ def main():
1484
1678
  p.add_argument("--since", type=int, default=0, help="Only messages with index >= N (default: 0)")
1485
1679
  p.set_defaults(func=cmd_chat_read)
1486
1680
 
1681
+ # -- whoami --------------------------------------------------------
1682
+ p = sub.add_parser(
1683
+ "whoami",
1684
+ help="Show the currently resolved session",
1685
+ description=(
1686
+ "Show which game, color, and token the CLI would use right now.\n"
1687
+ "Useful for verifying you're pointed at the correct game before acting."
1688
+ ),
1689
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1690
+ )
1691
+ p.add_argument("--game", help="Game ID (or set CLAWTAN_GAME)")
1692
+ p.add_argument("--token", help="Auth token (or set CLAWTAN_TOKEN)")
1693
+ p.add_argument("--color", help="Your color (or set CLAWTAN_COLOR)")
1694
+ p.set_defaults(func=cmd_whoami)
1695
+
1696
+ # -- clear-session -------------------------------------------------
1697
+ p = sub.add_parser(
1698
+ "clear-session",
1699
+ help="Remove saved session files",
1700
+ description=(
1701
+ "Remove saved session files to avoid stale credentials.\n"
1702
+ "With no flags, removes the default ~/.clawtan_session file.\n"
1703
+ "Use --game/--color to remove specific session files.\n"
1704
+ "Use --all to remove every saved session."
1705
+ ),
1706
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1707
+ )
1708
+ p.add_argument("--game", help="Only clear sessions for this game ID")
1709
+ p.add_argument("--color", help="Only clear sessions for this color")
1710
+ p.add_argument("--all", action="store_true", help="Remove ALL saved sessions")
1711
+ p.set_defaults(func=cmd_clear_session)
1712
+
1487
1713
  # -- Parse and run -------------------------------------------------
1488
1714
  args = parser.parse_args()
1489
1715
  if args.player:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawtan",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
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"