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