clawtan 0.2.1 → 0.2.2
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 +250 -13
- 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,34 @@ 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
|
+
cur = status.get("current_color", "?")
|
|
835
|
+
if phase_shown != "turn" or cur != prev_current:
|
|
638
836
|
print(f"Waiting for your turn (current: {cur})...", file=sys.stderr)
|
|
639
837
|
phase_shown = "turn"
|
|
838
|
+
prev_current = cur
|
|
839
|
+
|
|
840
|
+
# Live action feed: detect and display new game actions
|
|
841
|
+
try:
|
|
842
|
+
live_state = _get(f"/game/{game_id}")
|
|
843
|
+
records = live_state.get("action_records", [])
|
|
844
|
+
new_count = len(records)
|
|
845
|
+
if new_count > history_len:
|
|
846
|
+
if not live_header_shown:
|
|
847
|
+
print("── Game Progress ──", file=sys.stderr)
|
|
848
|
+
live_header_shown = True
|
|
849
|
+
for r in records[history_len:new_count]:
|
|
850
|
+
c, a, v = _unpack_record(r)
|
|
851
|
+
if c and a:
|
|
852
|
+
desc = _format_live_action(
|
|
853
|
+
c, a, v,
|
|
854
|
+
state=live_state,
|
|
855
|
+
pre_resources=pre_resources,
|
|
856
|
+
)
|
|
857
|
+
print(desc, file=sys.stderr)
|
|
858
|
+
history_len = new_count
|
|
859
|
+
pre_resources = _all_player_resources(live_state)
|
|
860
|
+
except (APIError, Exception):
|
|
861
|
+
pass
|
|
640
862
|
|
|
641
863
|
# Our turn!
|
|
642
864
|
if status.get("your_turn"):
|
|
@@ -680,11 +902,11 @@ def cmd_wait(args):
|
|
|
680
902
|
|
|
681
903
|
actions = state.get("current_playable_actions", [])
|
|
682
904
|
if actions:
|
|
683
|
-
_print_actions(actions, my_color=color)
|
|
905
|
+
_print_actions(actions, my_color=color, state=state)
|
|
684
906
|
|
|
685
907
|
robber = state.get("robber_coordinate")
|
|
686
908
|
if robber:
|
|
687
|
-
print(f"\n
|
|
909
|
+
print(f"\n Kraken: {robber}")
|
|
688
910
|
|
|
689
911
|
|
|
690
912
|
def _print_roll_result(state: dict, pre_resources: dict | None):
|
|
@@ -770,7 +992,7 @@ def cmd_act(args):
|
|
|
770
992
|
print(f" Current turn: {current} | Prompt: {prompt}", file=sys.stderr)
|
|
771
993
|
actions = state.get("current_playable_actions", [])
|
|
772
994
|
if actions:
|
|
773
|
-
_print_actions(actions, my_color=color)
|
|
995
|
+
_print_actions(actions, my_color=color, state=state)
|
|
774
996
|
print(
|
|
775
997
|
"\n Tip: run 'clawtan wait' to get a full turn briefing with available actions.",
|
|
776
998
|
file=sys.stderr,
|
|
@@ -815,14 +1037,14 @@ def cmd_act(args):
|
|
|
815
1037
|
_print_resources(my["resources"])
|
|
816
1038
|
|
|
817
1039
|
if my_actions:
|
|
818
|
-
_print_actions(actions, my_color=color)
|
|
1040
|
+
_print_actions(actions, my_color=color, state=state)
|
|
819
1041
|
else:
|
|
820
1042
|
print("\n No actions available.")
|
|
821
1043
|
elif my_actions:
|
|
822
1044
|
# We have actions even though current_color is someone else
|
|
823
1045
|
# (e.g. we also need to discard on a 7)
|
|
824
1046
|
print(f" Prompt: {prompt}")
|
|
825
|
-
_print_actions(actions, my_color=color)
|
|
1047
|
+
_print_actions(actions, my_color=color, state=state)
|
|
826
1048
|
else:
|
|
827
1049
|
print(f"\n Action done. No more actions available. Run 'clawtan wait' for your next turn or action required.")
|
|
828
1050
|
|
|
@@ -979,8 +1201,23 @@ def cmd_board(args):
|
|
|
979
1201
|
for r in roads:
|
|
980
1202
|
print(f" Edge {r['id']}: {r['color']}")
|
|
981
1203
|
|
|
1204
|
+
# Node adjacency graph for road planning
|
|
1205
|
+
adjacency = defaultdict(list)
|
|
1206
|
+
for e in state.get("edges", []):
|
|
1207
|
+
eid = e.get("id")
|
|
1208
|
+
if isinstance(eid, list) and len(eid) == 2:
|
|
1209
|
+
a, b = str(eid[0]), str(eid[1])
|
|
1210
|
+
adjacency[a].append(b)
|
|
1211
|
+
adjacency[b].append(a)
|
|
1212
|
+
|
|
1213
|
+
if adjacency:
|
|
1214
|
+
_section("Node Graph (edges)")
|
|
1215
|
+
for nid in sorted(adjacency.keys(), key=lambda x: int(x)):
|
|
1216
|
+
neighbors = sorted(adjacency[nid], key=lambda x: int(x))
|
|
1217
|
+
print(f" {nid}: {', '.join(neighbors)}")
|
|
1218
|
+
|
|
982
1219
|
if robber:
|
|
983
|
-
print(f"\n
|
|
1220
|
+
print(f"\n Kraken: {robber}")
|
|
984
1221
|
|
|
985
1222
|
|
|
986
1223
|
def cmd_chat(args):
|
|
@@ -1098,7 +1335,7 @@ def main():
|
|
|
1098
1335
|
" BUILD_CURRENT <edge> Build road, e.g. '[3,7]'\n"
|
|
1099
1336
|
" BUY_TREASURE_MAP Buy dev card\n"
|
|
1100
1337
|
" SUMMON_LOBSTER_GUARD Play knight card\n"
|
|
1101
|
-
" MOVE_THE_KRAKEN <val> Move
|
|
1338
|
+
" MOVE_THE_KRAKEN <val> Move Kraken, e.g. '[[0,1,-1],\"BLUE\",null]'\n"
|
|
1102
1339
|
" RELEASE_CATCH Discard half your cards (server selects randomly)\n"
|
|
1103
1340
|
" PLAY_BOUNTIFUL_HARVEST <r> Year of Plenty, e.g. '[\"DRIFTWOOD\",\"CORAL\"]'\n"
|
|
1104
1341
|
" PLAY_TIDAL_MONOPOLY <res> Monopoly, e.g. SHRIMP\n"
|
|
@@ -1142,7 +1379,7 @@ def main():
|
|
|
1142
1379
|
help="Show board layout, buildings, and roads",
|
|
1143
1380
|
description=(
|
|
1144
1381
|
"Display the board: tiles with resources/numbers, ports,\n"
|
|
1145
|
-
"buildings, roads, and
|
|
1382
|
+
"buildings, roads, and Kraken location.\n"
|
|
1146
1383
|
"Tile layout is static after game start -- call once and remember it."
|
|
1147
1384
|
),
|
|
1148
1385
|
)
|