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.
Files changed (2) hide show
  1. package/clawtan/cli.py +270 -19
  2. 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 robber: value = [coordinate, victim_color_or_null, null].\n"
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 _print_actions(actions: list, my_color: str | None = None):
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
- if phase_shown != "turn":
637
- cur = status.get("current_color", "?")
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 Robber: {robber}")
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 Robber: {robber}")
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 robber, e.g. '[[0,1,-1],\"BLUE\",null]'\n"
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 robber location.\n"
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
  )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawtan",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
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"