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.
- package/clawtan/cli.py +143 -13
- 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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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"
|