clawtan 0.2.1 → 0.2.3
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 +270 -19
- package/package.json +1 -1
package/clawtan/cli.py
CHANGED
|
@@ -396,7 +396,7 @@ _ACTION_HINTS = {
|
|
|
396
396
|
" CLI: clawtan act RELEASE_CATCH"
|
|
397
397
|
),
|
|
398
398
|
"MOVE_THE_KRAKEN": (
|
|
399
|
-
"Move
|
|
399
|
+
"Move Kraken: value = [coordinate, victim_color_or_null, null].\n"
|
|
400
400
|
" CLI: clawtan act MOVE_THE_KRAKEN '[[0,1,-1],\"BLUE\",null]'"
|
|
401
401
|
),
|
|
402
402
|
"OCEAN_TRADE": (
|
|
@@ -411,7 +411,101 @@ _ACTION_HINTS = {
|
|
|
411
411
|
}
|
|
412
412
|
|
|
413
413
|
|
|
414
|
-
def
|
|
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):
|
|
415
509
|
_section("Available Actions")
|
|
416
510
|
|
|
417
511
|
my_actions = []
|
|
@@ -434,12 +528,25 @@ def _print_actions(actions: list, my_color: str | None = None):
|
|
|
434
528
|
val = a[2] if isinstance(a, list) and len(a) > 2 else None
|
|
435
529
|
grouped[atype].append(val)
|
|
436
530
|
|
|
531
|
+
annotated_types = {"BUILD_CURRENT", "BUILD_TIDE_POOL", "BUILD_REEF"}
|
|
532
|
+
|
|
437
533
|
for atype, values in grouped.items():
|
|
438
534
|
hint = _ACTION_HINTS.get(atype)
|
|
439
535
|
if all(v is None for v in values):
|
|
440
536
|
print(f" {atype}")
|
|
441
537
|
if hint:
|
|
442
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}")
|
|
443
550
|
else:
|
|
444
551
|
formatted = [json.dumps(v, separators=(",", ":")) for v in values]
|
|
445
552
|
if hint:
|
|
@@ -495,6 +602,94 @@ def _print_history(records: list, since: int = 0):
|
|
|
495
602
|
print(f" {r}")
|
|
496
603
|
|
|
497
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
|
+
|
|
498
693
|
def _count_turns(state: dict) -> int:
|
|
499
694
|
"""Count ROLL_THE_SHELLS records to get the turn number."""
|
|
500
695
|
count = 0
|
|
@@ -582,10 +777,12 @@ def cmd_wait(args):
|
|
|
582
777
|
# Snapshot current history/chat counts so we can show "what's new"
|
|
583
778
|
history_len = 0
|
|
584
779
|
chat_since = 0
|
|
780
|
+
pre_resources = None
|
|
585
781
|
try:
|
|
586
782
|
state = _get(f"/game/{game_id}")
|
|
587
783
|
if state.get("started"):
|
|
588
784
|
history_len = len(state.get("action_records", []))
|
|
785
|
+
pre_resources = _all_player_resources(state)
|
|
589
786
|
chat_resp = _get(f"/game/{game_id}/chat")
|
|
590
787
|
chat_since = len(chat_resp.get("messages", []))
|
|
591
788
|
except (APIError, Exception):
|
|
@@ -593,6 +790,8 @@ def cmd_wait(args):
|
|
|
593
790
|
|
|
594
791
|
# Poll loop
|
|
595
792
|
phase_shown = None
|
|
793
|
+
prev_current = None
|
|
794
|
+
live_header_shown = False
|
|
596
795
|
while True:
|
|
597
796
|
try:
|
|
598
797
|
status = _get(f"/game/{game_id}/status", token=token)
|
|
@@ -611,7 +810,6 @@ def cmd_wait(args):
|
|
|
611
810
|
_header("GAME OVER")
|
|
612
811
|
winner = status["winning_color"]
|
|
613
812
|
print(f" Winner: {winner}")
|
|
614
|
-
# Fetch final state for scores
|
|
615
813
|
try:
|
|
616
814
|
state = _get(f"/game/{game_id}")
|
|
617
815
|
colors = state.get("colors", [])
|
|
@@ -633,10 +831,35 @@ def cmd_wait(args):
|
|
|
633
831
|
print(f"Waiting for players ({pj}/{np})...", file=sys.stderr)
|
|
634
832
|
phase_shown = "lobby"
|
|
635
833
|
else:
|
|
636
|
-
|
|
637
|
-
|
|
834
|
+
# Live action feed: detect and display new game actions
|
|
835
|
+
# (fetched BEFORE the waiting message so actions print in order)
|
|
836
|
+
try:
|
|
837
|
+
live_state = _get(f"/game/{game_id}")
|
|
838
|
+
records = live_state.get("action_records", [])
|
|
839
|
+
new_count = len(records)
|
|
840
|
+
if new_count > history_len:
|
|
841
|
+
if not live_header_shown:
|
|
842
|
+
print("── Game Progress ──", file=sys.stderr)
|
|
843
|
+
live_header_shown = True
|
|
844
|
+
for r in records[history_len:new_count]:
|
|
845
|
+
c, a, v = _unpack_record(r)
|
|
846
|
+
if c and a:
|
|
847
|
+
desc = _format_live_action(
|
|
848
|
+
c, a, v,
|
|
849
|
+
state=live_state,
|
|
850
|
+
pre_resources=pre_resources,
|
|
851
|
+
)
|
|
852
|
+
print(desc, file=sys.stderr)
|
|
853
|
+
history_len = new_count
|
|
854
|
+
pre_resources = _all_player_resources(live_state)
|
|
855
|
+
except (APIError, Exception):
|
|
856
|
+
pass
|
|
857
|
+
|
|
858
|
+
cur = status.get("current_color", "?")
|
|
859
|
+
if phase_shown != "turn" or cur != prev_current:
|
|
638
860
|
print(f"Waiting for your turn (current: {cur})...", file=sys.stderr)
|
|
639
861
|
phase_shown = "turn"
|
|
862
|
+
prev_current = cur
|
|
640
863
|
|
|
641
864
|
# Our turn!
|
|
642
865
|
if status.get("your_turn"):
|
|
@@ -680,11 +903,11 @@ def cmd_wait(args):
|
|
|
680
903
|
|
|
681
904
|
actions = state.get("current_playable_actions", [])
|
|
682
905
|
if actions:
|
|
683
|
-
_print_actions(actions, my_color=color)
|
|
906
|
+
_print_actions(actions, my_color=color, state=state)
|
|
684
907
|
|
|
685
908
|
robber = state.get("robber_coordinate")
|
|
686
909
|
if robber:
|
|
687
|
-
print(f"\n
|
|
910
|
+
print(f"\n Kraken: {robber}")
|
|
688
911
|
|
|
689
912
|
|
|
690
913
|
def _print_roll_result(state: dict, pre_resources: dict | None):
|
|
@@ -758,19 +981,32 @@ def cmd_act(args):
|
|
|
758
981
|
except APIError as e:
|
|
759
982
|
print(f"ERROR: {args.action} failed.", file=sys.stderr)
|
|
760
983
|
if "not a valid action" in e.detail.lower():
|
|
761
|
-
print(
|
|
762
|
-
f" '{args.action}' is not available right now.",
|
|
763
|
-
file=sys.stderr,
|
|
764
|
-
)
|
|
765
|
-
# Fetch current state to show what IS available
|
|
766
984
|
try:
|
|
767
985
|
state = _get(f"/game/{game_id}")
|
|
768
986
|
prompt = state.get("current_prompt", "?")
|
|
769
987
|
current = state.get("current_color", "?")
|
|
770
|
-
print(f" Current turn: {current} | Prompt: {prompt}", file=sys.stderr)
|
|
771
988
|
actions = state.get("current_playable_actions", [])
|
|
989
|
+
|
|
990
|
+
available_types = set()
|
|
991
|
+
for a in actions:
|
|
992
|
+
if isinstance(a, list) and len(a) > 1:
|
|
993
|
+
available_types.add(a[1])
|
|
994
|
+
|
|
995
|
+
if args.action in available_types:
|
|
996
|
+
val_str = json.dumps(value, separators=(",", ":")) if value is not None else "(none)"
|
|
997
|
+
print(
|
|
998
|
+
f" '{args.action}' is available, but the value {val_str} is not a valid option.",
|
|
999
|
+
file=sys.stderr,
|
|
1000
|
+
)
|
|
1001
|
+
else:
|
|
1002
|
+
print(
|
|
1003
|
+
f" '{args.action}' is not available right now.",
|
|
1004
|
+
file=sys.stderr,
|
|
1005
|
+
)
|
|
1006
|
+
|
|
1007
|
+
print(f" Current turn: {current} | Prompt: {prompt}", file=sys.stderr)
|
|
772
1008
|
if actions:
|
|
773
|
-
_print_actions(actions, my_color=color)
|
|
1009
|
+
_print_actions(actions, my_color=color, state=state)
|
|
774
1010
|
print(
|
|
775
1011
|
"\n Tip: run 'clawtan wait' to get a full turn briefing with available actions.",
|
|
776
1012
|
file=sys.stderr,
|
|
@@ -815,14 +1051,14 @@ def cmd_act(args):
|
|
|
815
1051
|
_print_resources(my["resources"])
|
|
816
1052
|
|
|
817
1053
|
if my_actions:
|
|
818
|
-
_print_actions(actions, my_color=color)
|
|
1054
|
+
_print_actions(actions, my_color=color, state=state)
|
|
819
1055
|
else:
|
|
820
1056
|
print("\n No actions available.")
|
|
821
1057
|
elif my_actions:
|
|
822
1058
|
# We have actions even though current_color is someone else
|
|
823
1059
|
# (e.g. we also need to discard on a 7)
|
|
824
1060
|
print(f" Prompt: {prompt}")
|
|
825
|
-
_print_actions(actions, my_color=color)
|
|
1061
|
+
_print_actions(actions, my_color=color, state=state)
|
|
826
1062
|
else:
|
|
827
1063
|
print(f"\n Action done. No more actions available. Run 'clawtan wait' for your next turn or action required.")
|
|
828
1064
|
|
|
@@ -979,8 +1215,23 @@ def cmd_board(args):
|
|
|
979
1215
|
for r in roads:
|
|
980
1216
|
print(f" Edge {r['id']}: {r['color']}")
|
|
981
1217
|
|
|
1218
|
+
# Node adjacency graph for road planning
|
|
1219
|
+
adjacency = defaultdict(list)
|
|
1220
|
+
for e in state.get("edges", []):
|
|
1221
|
+
eid = e.get("id")
|
|
1222
|
+
if isinstance(eid, list) and len(eid) == 2:
|
|
1223
|
+
a, b = str(eid[0]), str(eid[1])
|
|
1224
|
+
adjacency[a].append(b)
|
|
1225
|
+
adjacency[b].append(a)
|
|
1226
|
+
|
|
1227
|
+
if adjacency:
|
|
1228
|
+
_section("Node Graph (edges)")
|
|
1229
|
+
for nid in sorted(adjacency.keys(), key=lambda x: int(x)):
|
|
1230
|
+
neighbors = sorted(adjacency[nid], key=lambda x: int(x))
|
|
1231
|
+
print(f" {nid}: {', '.join(neighbors)}")
|
|
1232
|
+
|
|
982
1233
|
if robber:
|
|
983
|
-
print(f"\n
|
|
1234
|
+
print(f"\n Kraken: {robber}")
|
|
984
1235
|
|
|
985
1236
|
|
|
986
1237
|
def cmd_chat(args):
|
|
@@ -1098,7 +1349,7 @@ def main():
|
|
|
1098
1349
|
" BUILD_CURRENT <edge> Build road, e.g. '[3,7]'\n"
|
|
1099
1350
|
" BUY_TREASURE_MAP Buy dev card\n"
|
|
1100
1351
|
" SUMMON_LOBSTER_GUARD Play knight card\n"
|
|
1101
|
-
" MOVE_THE_KRAKEN <val> Move
|
|
1352
|
+
" MOVE_THE_KRAKEN <val> Move Kraken, e.g. '[[0,1,-1],\"BLUE\",null]'\n"
|
|
1102
1353
|
" RELEASE_CATCH Discard half your cards (server selects randomly)\n"
|
|
1103
1354
|
" PLAY_BOUNTIFUL_HARVEST <r> Year of Plenty, e.g. '[\"DRIFTWOOD\",\"CORAL\"]'\n"
|
|
1104
1355
|
" PLAY_TIDAL_MONOPOLY <res> Monopoly, e.g. SHRIMP\n"
|
|
@@ -1142,7 +1393,7 @@ def main():
|
|
|
1142
1393
|
help="Show board layout, buildings, and roads",
|
|
1143
1394
|
description=(
|
|
1144
1395
|
"Display the board: tiles with resources/numbers, ports,\n"
|
|
1145
|
-
"buildings, roads, and
|
|
1396
|
+
"buildings, roads, and Kraken location.\n"
|
|
1146
1397
|
"Tile layout is static after game start -- call once and remember it."
|
|
1147
1398
|
),
|
|
1148
1399
|
)
|