clawtan 0.1.4 → 0.1.9

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 +143 -13
  2. package/package.json +1 -1
package/clawtan/cli.py CHANGED
@@ -66,6 +66,9 @@ def _base() -> str:
66
66
  return url.rstrip("/")
67
67
 
68
68
 
69
+ _DEBUG = os.environ.get("CLAWTAN_DEBUG", "").lower() in ("1", "true", "yes")
70
+
71
+
69
72
  def _req(method: str, path: str, data=None, token=None):
70
73
  url = f"{_base()}{path}"
71
74
  body = json.dumps(data).encode() if data is not None else None
@@ -76,17 +79,32 @@ def _req(method: str, path: str, data=None, token=None):
76
79
  if token:
77
80
  headers["Authorization"] = token
78
81
  req = urllib.request.Request(url, data=body, headers=headers, method=method)
82
+
83
+ if _DEBUG:
84
+ print(f"[DEBUG] {method} {url}", file=sys.stderr)
85
+ if body:
86
+ print(f"[DEBUG] Body: {body.decode()}", file=sys.stderr)
87
+
79
88
  try:
80
89
  with urllib.request.urlopen(req) as r:
81
- return json.loads(r.read())
90
+ raw = r.read()
91
+ if _DEBUG:
92
+ print(f"[DEBUG] {r.status} ({len(raw)} bytes)", file=sys.stderr)
93
+ return json.loads(raw)
82
94
  except urllib.error.HTTPError as e:
95
+ raw_body = e.read()
96
+ if _DEBUG:
97
+ print(f"[DEBUG] HTTP {e.code}: {raw_body[:500]}", file=sys.stderr)
98
+ print(f"[DEBUG] Headers: {dict(e.headers)}", file=sys.stderr)
83
99
  try:
84
- detail = json.loads(e.read()).get("detail", str(e))
100
+ detail = json.loads(raw_body).get("detail", str(e))
85
101
  except Exception:
86
102
  detail = str(e)
87
103
  raise APIError(e.code, detail)
88
104
  except urllib.error.URLError as e:
89
105
  reason = str(e.reason)
106
+ if _DEBUG:
107
+ print(f"[DEBUG] URLError: {reason}", file=sys.stderr)
90
108
  if isinstance(e.reason, ssl.SSLError) or "SSL" in reason or "CERTIFICATE" in reason:
91
109
  raise APIError(
92
110
  0,
@@ -173,6 +191,17 @@ def _my_status(state: dict, color: str) -> dict | None:
173
191
  }
174
192
 
175
193
 
194
+ def _all_player_resources(state: dict) -> dict:
195
+ """Return {color: {resource: count}} for every player."""
196
+ ps = state.get("player_state", {})
197
+ colors = state.get("colors", [])
198
+ result = {}
199
+ for i, c in enumerate(colors):
200
+ p = f"P{i}_"
201
+ result[c] = {r: ps.get(f"{p}{r}_IN_HAND", 0) for r in RESOURCES}
202
+ return result
203
+
204
+
176
205
  def _opponents(state: dict, color: str) -> list:
177
206
  ps = state.get("player_state", {})
178
207
  colors = state.get("colors", [])
@@ -202,9 +231,9 @@ def _opponents(state: dict, color: str) -> list:
202
231
  return result
203
232
 
204
233
 
205
- # ---------------------------------------------------------------------------
234
+ # --------------------------------------------------------------------------
206
235
  # Text formatters
207
- # ---------------------------------------------------------------------------
236
+ # --------------------------------------------------------------------------
208
237
  def _header(title: str):
209
238
  print(f"\n=== {title} ===")
210
239
 
@@ -259,26 +288,76 @@ def _print_opponents(opponents: list):
259
288
  print(line)
260
289
 
261
290
 
262
- def _print_actions(actions: list):
291
+ _ACTION_HINTS = {
292
+ "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]'"
297
+ ),
298
+ "MOVE_THE_KRAKEN": (
299
+ "Move robber: value = [coordinate, victim_color_or_null, null].\n"
300
+ " CLI: clawtan act MOVE_THE_KRAKEN '[[0,1,-1],\"BLUE\",null]'"
301
+ ),
302
+ "OCEAN_TRADE": (
303
+ "Maritime trade: give 4 (or 3/2 with port) of one resource, receive 1.\n"
304
+ " Value = list of resources: first N are given, last 1 is received.\n"
305
+ " CLI: clawtan act OCEAN_TRADE '[\"KELP\",\"KELP\",\"KELP\",\"KELP\",\"SHRIMP\"]'"
306
+ ),
307
+ "PLAY_BOUNTIFUL_HARVEST": (
308
+ "Year of Plenty: pick 2 free resources.\n"
309
+ " CLI: clawtan act PLAY_BOUNTIFUL_HARVEST '[\"DRIFTWOOD\",\"CORAL\"]'"
310
+ ),
311
+ }
312
+
313
+
314
+ def _print_actions(actions: list, my_color: str | None = None):
263
315
  _section("Available Actions")
264
- grouped = defaultdict(list)
316
+
317
+ my_actions = []
318
+ other_colors = set()
265
319
  for a in actions:
320
+ if isinstance(a, list) and len(a) > 1:
321
+ action_color = a[0]
322
+ if my_color and action_color and action_color != my_color:
323
+ other_colors.add(action_color)
324
+ continue
325
+ my_actions.append(a)
326
+
327
+ if not my_actions and other_colors:
328
+ print(f" (none for you -- waiting on: {', '.join(sorted(other_colors))})")
329
+ return
330
+
331
+ grouped = defaultdict(list)
332
+ for a in my_actions:
266
333
  atype = a[1] if isinstance(a, list) and len(a) > 1 else str(a)
267
334
  val = a[2] if isinstance(a, list) and len(a) > 2 else None
268
335
  grouped[atype].append(val)
269
336
 
270
337
  for atype, values in grouped.items():
338
+ hint = _ACTION_HINTS.get(atype)
271
339
  if all(v is None for v in values):
272
340
  print(f" {atype}")
341
+ if hint:
342
+ print(f" ({hint})")
273
343
  else:
274
344
  formatted = [json.dumps(v, separators=(",", ":")) for v in values]
275
- joined = " | ".join(formatted)
276
- if len(joined) + len(atype) + 4 <= 120:
277
- print(f" {atype}: {joined}")
278
- else:
345
+ if hint:
279
346
  print(f" {atype} ({len(values)} options):")
347
+ print(f" ({hint})")
280
348
  for f in formatted:
281
349
  print(f" {f}")
350
+ else:
351
+ joined = " | ".join(formatted)
352
+ if len(joined) + len(atype) + 4 <= 120:
353
+ print(f" {atype}: {joined}")
354
+ else:
355
+ print(f" {atype} ({len(values)} options):")
356
+ for f in formatted:
357
+ print(f" {f}")
358
+
359
+ if other_colors:
360
+ print(f"\n (other players still need to act: {', '.join(sorted(other_colors))})")
282
361
 
283
362
 
284
363
  def _print_history(records: list, since: int = 0):
@@ -459,18 +538,65 @@ def cmd_wait(args):
459
538
 
460
539
  actions = state.get("current_playable_actions", [])
461
540
  if actions:
462
- _print_actions(actions)
541
+ _print_actions(actions, my_color=color)
463
542
 
464
543
  robber = state.get("robber_coordinate")
465
544
  if robber:
466
545
  print(f"\n Robber: {robber}")
467
546
 
468
547
 
548
+ def _print_roll_result(state: dict, pre_resources: dict | None):
549
+ """Show dice result and per-player resource gains after a roll."""
550
+ # Extract the roll value from the last action record
551
+ records = state.get("action_records", [])
552
+ roll_val = None
553
+ 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
556
+ break
557
+
558
+ if roll_val is not None:
559
+ if isinstance(roll_val, list) and len(roll_val) == 2:
560
+ print(f" Rolled: {roll_val[0]} + {roll_val[1]} = {sum(roll_val)}")
561
+ else:
562
+ print(f" Rolled: {roll_val}")
563
+
564
+ # Diff resources to show what was distributed
565
+ if pre_resources:
566
+ post_resources = _all_player_resources(state)
567
+ _section("Resources Distributed")
568
+ any_gains = False
569
+ for c in state.get("colors", []):
570
+ pre = pre_resources.get(c, {})
571
+ post = post_resources.get(c, {})
572
+ gains = []
573
+ for res in RESOURCES:
574
+ diff = post.get(res, 0) - pre.get(res, 0)
575
+ if diff > 0:
576
+ gains.append(f"+{diff} {res}")
577
+ elif diff < 0:
578
+ gains.append(f"{diff} {res}")
579
+ if gains:
580
+ any_gains = True
581
+ print(f" {c}: {', '.join(gains)}")
582
+ if not any_gains:
583
+ print(" No resources produced.")
584
+
585
+
469
586
  def cmd_act(args):
470
587
  game_id = _env("GAME", args.game)
471
588
  token = _env("TOKEN", args.token)
472
589
  color = _env("COLOR", args.color)
473
590
 
591
+ # Snapshot resources before rolling so we can diff afterwards
592
+ pre_resources = None
593
+ if args.action == "ROLL_THE_SHELLS":
594
+ try:
595
+ pre_state = _get(f"/game/{game_id}")
596
+ pre_resources = _all_player_resources(pre_state)
597
+ except (APIError, Exception):
598
+ pass
599
+
474
600
  # Parse value: try JSON, fall back to bare string
475
601
  value = None
476
602
  if args.value is not None:
@@ -493,6 +619,10 @@ def cmd_act(args):
493
619
  state = _get(f"/game/{game_id}")
494
620
  current_color = state.get("current_color")
495
621
 
622
+ # After a roll, show the dice result and resource distribution
623
+ if args.action == "ROLL_THE_SHELLS":
624
+ _print_roll_result(state, pre_resources)
625
+
496
626
  if current_color == color:
497
627
  prompt = state.get("current_prompt", "?")
498
628
  print(f" Prompt: {prompt}")
@@ -504,7 +634,7 @@ def cmd_act(args):
504
634
 
505
635
  actions = state.get("current_playable_actions", [])
506
636
  if actions:
507
- _print_actions(actions)
637
+ _print_actions(actions, my_color=color)
508
638
  else:
509
639
  print("\n No actions available.")
510
640
  else:
@@ -721,7 +851,7 @@ def main():
721
851
  " BUY_TREASURE_MAP Buy dev card\n"
722
852
  " SUMMON_LOBSTER_GUARD Play knight card\n"
723
853
  " MOVE_THE_KRAKEN <val> Move robber, e.g. '[[0,1,-1],\"BLUE\",null]'\n"
724
- " RELEASE_CATCH <freqdeck> Discard cards, e.g. '[1,0,0,1,0]'\n"
854
+ " RELEASE_CATCH [freqdeck] Discard cards (no value = random), e.g. '[1,0,0,1,0]'\n"
725
855
  " PLAY_BOUNTIFUL_HARVEST <r> Year of Plenty, e.g. '[\"DRIFTWOOD\",\"CORAL\"]'\n"
726
856
  " PLAY_TIDAL_MONOPOLY <res> Monopoly, e.g. SHRIMP\n"
727
857
  " PLAY_CURRENT_BUILDING Road Building\n"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawtan",
3
- "version": "0.1.4",
3
+ "version": "0.1.9",
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"