clawtan 0.2.0 → 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 +368 -45
- package/package.json +1 -1
package/clawtan/cli.py
CHANGED
|
@@ -14,10 +14,24 @@ Typical agent flow:
|
|
|
14
14
|
clawtan act END_TIDE
|
|
15
15
|
clawtan wait # next turn...
|
|
16
16
|
|
|
17
|
+
Multi-player on one machine (each terminal):
|
|
18
|
+
clawtan join <GAME_ID> # join, note your assigned color
|
|
19
|
+
export CLAWTAN_COLOR=<COLOR> # lock this terminal to your player
|
|
20
|
+
clawtan wait # now uses the correct session
|
|
21
|
+
|
|
22
|
+
Or use --player on every call (works with separate exec calls):
|
|
23
|
+
clawtan --player BLUE wait
|
|
24
|
+
clawtan --player BLUE act ROLL_THE_SHELLS
|
|
25
|
+
|
|
26
|
+
Same color in multiple games — add --game:
|
|
27
|
+
clawtan --player RED wait --game <GAME_ID>
|
|
28
|
+
|
|
17
29
|
Session lookup order (per field):
|
|
18
|
-
1. CLI flags (--game, --token, --color)
|
|
30
|
+
1. CLI flags (--game, --token, --color, --player)
|
|
19
31
|
2. Environment variables (CLAWTAN_GAME, CLAWTAN_TOKEN, CLAWTAN_COLOR)
|
|
20
|
-
3. Session file
|
|
32
|
+
3. Session file: ~/.clawtan_sessions/<game>_<color>.json matched by
|
|
33
|
+
available hints, falling back to ~/.clawtan_session.
|
|
34
|
+
Override with CLAWTAN_SESSION_FILE.
|
|
21
35
|
"""
|
|
22
36
|
|
|
23
37
|
import argparse
|
|
@@ -126,42 +140,97 @@ def _get(path, token=None):
|
|
|
126
140
|
# ---------------------------------------------------------------------------
|
|
127
141
|
# Session file helpers
|
|
128
142
|
# ---------------------------------------------------------------------------
|
|
129
|
-
|
|
130
|
-
return os.environ.get("CLAWTAN_SESSION_FILE") or os.path.expanduser("~/.clawtan_session")
|
|
143
|
+
_SESSIONS_DIR = os.path.expanduser("~/.clawtan_sessions")
|
|
131
144
|
|
|
132
145
|
|
|
133
146
|
def _save_session(game_id: str, token: str, color: str):
|
|
134
|
-
path = _session_path()
|
|
135
147
|
data = {"GAME": game_id, "TOKEN": token, "COLOR": color}
|
|
148
|
+
os.makedirs(_SESSIONS_DIR, exist_ok=True)
|
|
149
|
+
path = os.path.join(_SESSIONS_DIR, f"{game_id}_{color}.json")
|
|
136
150
|
try:
|
|
137
151
|
with open(path, "w") as f:
|
|
138
152
|
json.dump(data, f)
|
|
139
153
|
except OSError as e:
|
|
140
154
|
print(f"Warning: could not save session to {path}: {e}", file=sys.stderr)
|
|
155
|
+
default = os.environ.get("CLAWTAN_SESSION_FILE") or os.path.expanduser("~/.clawtan_session")
|
|
156
|
+
try:
|
|
157
|
+
with open(default, "w") as f:
|
|
158
|
+
json.dump(data, f)
|
|
159
|
+
except OSError:
|
|
160
|
+
pass
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _find_session(game_hint: str | None = None, color_hint: str | None = None) -> dict:
|
|
164
|
+
"""Find a session file using available game/color hints."""
|
|
165
|
+
custom = os.environ.get("CLAWTAN_SESSION_FILE")
|
|
166
|
+
if custom:
|
|
167
|
+
try:
|
|
168
|
+
with open(custom) as f:
|
|
169
|
+
return json.load(f)
|
|
170
|
+
except (OSError, json.JSONDecodeError):
|
|
171
|
+
return {}
|
|
172
|
+
|
|
173
|
+
if game_hint and color_hint:
|
|
174
|
+
path = os.path.join(_SESSIONS_DIR, f"{game_hint}_{color_hint}.json")
|
|
175
|
+
try:
|
|
176
|
+
with open(path) as f:
|
|
177
|
+
return json.load(f)
|
|
178
|
+
except (OSError, json.JSONDecodeError):
|
|
179
|
+
pass
|
|
141
180
|
|
|
181
|
+
if (game_hint or color_hint) and os.path.isdir(_SESSIONS_DIR):
|
|
182
|
+
matches = []
|
|
183
|
+
for fname in os.listdir(_SESSIONS_DIR):
|
|
184
|
+
if not fname.endswith(".json"):
|
|
185
|
+
continue
|
|
186
|
+
try:
|
|
187
|
+
with open(os.path.join(_SESSIONS_DIR, fname)) as f:
|
|
188
|
+
data = json.load(f)
|
|
189
|
+
if game_hint and data.get("GAME") != game_hint:
|
|
190
|
+
continue
|
|
191
|
+
if color_hint and data.get("COLOR") != color_hint:
|
|
192
|
+
continue
|
|
193
|
+
matches.append(data)
|
|
194
|
+
except (OSError, json.JSONDecodeError):
|
|
195
|
+
continue
|
|
196
|
+
if len(matches) == 1:
|
|
197
|
+
return matches[0]
|
|
198
|
+
if matches:
|
|
199
|
+
return matches[0]
|
|
142
200
|
|
|
143
|
-
|
|
144
|
-
path = _session_path()
|
|
201
|
+
default = os.path.expanduser("~/.clawtan_session")
|
|
145
202
|
try:
|
|
146
|
-
with open(
|
|
203
|
+
with open(default) as f:
|
|
147
204
|
return json.load(f)
|
|
148
205
|
except (OSError, json.JSONDecodeError):
|
|
149
206
|
return {}
|
|
150
207
|
|
|
151
208
|
|
|
152
209
|
# ---------------------------------------------------------------------------
|
|
153
|
-
#
|
|
210
|
+
# Session resolution
|
|
154
211
|
# ---------------------------------------------------------------------------
|
|
155
|
-
def
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
212
|
+
def _resolve_session(arg_game=None, arg_token=None, arg_color=None):
|
|
213
|
+
"""Resolve game, token, color from flags -> env vars -> session file.
|
|
214
|
+
|
|
215
|
+
Uses all available hints together to find the correct session file,
|
|
216
|
+
which avoids ambiguity when multiple games or players share a machine.
|
|
217
|
+
"""
|
|
218
|
+
game = arg_game or os.environ.get("CLAWTAN_GAME") or None
|
|
219
|
+
token = arg_token or os.environ.get("CLAWTAN_TOKEN") or None
|
|
220
|
+
color = arg_color or os.environ.get("CLAWTAN_COLOR") or None
|
|
221
|
+
|
|
222
|
+
if not (game and token and color):
|
|
223
|
+
session = _find_session(game_hint=game, color_hint=color)
|
|
224
|
+
game = game or session.get("GAME")
|
|
225
|
+
token = token or session.get("TOKEN")
|
|
226
|
+
color = color or session.get("COLOR")
|
|
227
|
+
|
|
228
|
+
return game, token, color
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _require(name: str, val):
|
|
232
|
+
"""Exit with error if a required session value is missing."""
|
|
162
233
|
if not val:
|
|
163
|
-
val = _load_session().get(name)
|
|
164
|
-
if required and not val:
|
|
165
234
|
print(
|
|
166
235
|
f"ERROR: Missing {name}. Pass --{name.lower()}, set CLAWTAN_{name},"
|
|
167
236
|
f" or run 'clawtan quick-join' to create a session.",
|
|
@@ -327,7 +396,7 @@ _ACTION_HINTS = {
|
|
|
327
396
|
" CLI: clawtan act RELEASE_CATCH"
|
|
328
397
|
),
|
|
329
398
|
"MOVE_THE_KRAKEN": (
|
|
330
|
-
"Move
|
|
399
|
+
"Move Kraken: value = [coordinate, victim_color_or_null, null].\n"
|
|
331
400
|
" CLI: clawtan act MOVE_THE_KRAKEN '[[0,1,-1],\"BLUE\",null]'"
|
|
332
401
|
),
|
|
333
402
|
"OCEAN_TRADE": (
|
|
@@ -342,7 +411,101 @@ _ACTION_HINTS = {
|
|
|
342
411
|
}
|
|
343
412
|
|
|
344
413
|
|
|
345
|
-
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):
|
|
346
509
|
_section("Available Actions")
|
|
347
510
|
|
|
348
511
|
my_actions = []
|
|
@@ -365,12 +528,25 @@ def _print_actions(actions: list, my_color: str | None = None):
|
|
|
365
528
|
val = a[2] if isinstance(a, list) and len(a) > 2 else None
|
|
366
529
|
grouped[atype].append(val)
|
|
367
530
|
|
|
531
|
+
annotated_types = {"BUILD_CURRENT", "BUILD_TIDE_POOL", "BUILD_REEF"}
|
|
532
|
+
|
|
368
533
|
for atype, values in grouped.items():
|
|
369
534
|
hint = _ACTION_HINTS.get(atype)
|
|
370
535
|
if all(v is None for v in values):
|
|
371
536
|
print(f" {atype}")
|
|
372
537
|
if hint:
|
|
373
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}")
|
|
374
550
|
else:
|
|
375
551
|
formatted = [json.dumps(v, separators=(",", ":")) for v in values]
|
|
376
552
|
if hint:
|
|
@@ -426,6 +602,94 @@ def _print_history(records: list, since: int = 0):
|
|
|
426
602
|
print(f" {r}")
|
|
427
603
|
|
|
428
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
|
+
|
|
429
693
|
def _count_turns(state: dict) -> int:
|
|
430
694
|
"""Count ROLL_THE_SHELLS records to get the turn number."""
|
|
431
695
|
count = 0
|
|
@@ -493,24 +757,32 @@ def _print_join(resp: dict):
|
|
|
493
757
|
print(f" Started: {'yes' if resp.get('game_started') else 'no'}")
|
|
494
758
|
|
|
495
759
|
_save_session(resp["game_id"], resp["token"], resp["player_color"])
|
|
496
|
-
|
|
497
|
-
|
|
760
|
+
gid = resp["game_id"]
|
|
761
|
+
color = resp["player_color"]
|
|
762
|
+
print(f"\n Session saved to ~/.clawtan_sessions/{gid}_{color}.json")
|
|
763
|
+
print(f"\n Multi-player setup — pick ONE option:")
|
|
764
|
+
print(f" export CLAWTAN_COLOR={color} # env var per terminal")
|
|
765
|
+
print(f" clawtan --player {color} <command> # flag per command")
|
|
766
|
+
print(f" clawtan --player {color} wait --game {gid} # if same color in multiple games")
|
|
498
767
|
|
|
499
768
|
|
|
500
769
|
def cmd_wait(args):
|
|
501
|
-
game_id =
|
|
502
|
-
|
|
503
|
-
|
|
770
|
+
game_id, token, color = _resolve_session(args.game, args.token, args.color)
|
|
771
|
+
_require("GAME", game_id)
|
|
772
|
+
_require("TOKEN", token)
|
|
773
|
+
_require("COLOR", color)
|
|
504
774
|
poll = args.poll
|
|
505
775
|
deadline = time.monotonic() + args.timeout
|
|
506
776
|
|
|
507
777
|
# Snapshot current history/chat counts so we can show "what's new"
|
|
508
778
|
history_len = 0
|
|
509
779
|
chat_since = 0
|
|
780
|
+
pre_resources = None
|
|
510
781
|
try:
|
|
511
782
|
state = _get(f"/game/{game_id}")
|
|
512
783
|
if state.get("started"):
|
|
513
784
|
history_len = len(state.get("action_records", []))
|
|
785
|
+
pre_resources = _all_player_resources(state)
|
|
514
786
|
chat_resp = _get(f"/game/{game_id}/chat")
|
|
515
787
|
chat_since = len(chat_resp.get("messages", []))
|
|
516
788
|
except (APIError, Exception):
|
|
@@ -518,6 +790,8 @@ def cmd_wait(args):
|
|
|
518
790
|
|
|
519
791
|
# Poll loop
|
|
520
792
|
phase_shown = None
|
|
793
|
+
prev_current = None
|
|
794
|
+
live_header_shown = False
|
|
521
795
|
while True:
|
|
522
796
|
try:
|
|
523
797
|
status = _get(f"/game/{game_id}/status", token=token)
|
|
@@ -536,7 +810,6 @@ def cmd_wait(args):
|
|
|
536
810
|
_header("GAME OVER")
|
|
537
811
|
winner = status["winning_color"]
|
|
538
812
|
print(f" Winner: {winner}")
|
|
539
|
-
# Fetch final state for scores
|
|
540
813
|
try:
|
|
541
814
|
state = _get(f"/game/{game_id}")
|
|
542
815
|
colors = state.get("colors", [])
|
|
@@ -558,10 +831,34 @@ def cmd_wait(args):
|
|
|
558
831
|
print(f"Waiting for players ({pj}/{np})...", file=sys.stderr)
|
|
559
832
|
phase_shown = "lobby"
|
|
560
833
|
else:
|
|
561
|
-
|
|
562
|
-
|
|
834
|
+
cur = status.get("current_color", "?")
|
|
835
|
+
if phase_shown != "turn" or cur != prev_current:
|
|
563
836
|
print(f"Waiting for your turn (current: {cur})...", file=sys.stderr)
|
|
564
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
|
|
565
862
|
|
|
566
863
|
# Our turn!
|
|
567
864
|
if status.get("your_turn"):
|
|
@@ -605,11 +902,11 @@ def cmd_wait(args):
|
|
|
605
902
|
|
|
606
903
|
actions = state.get("current_playable_actions", [])
|
|
607
904
|
if actions:
|
|
608
|
-
_print_actions(actions, my_color=color)
|
|
905
|
+
_print_actions(actions, my_color=color, state=state)
|
|
609
906
|
|
|
610
907
|
robber = state.get("robber_coordinate")
|
|
611
908
|
if robber:
|
|
612
|
-
print(f"\n
|
|
909
|
+
print(f"\n Kraken: {robber}")
|
|
613
910
|
|
|
614
911
|
|
|
615
912
|
def _print_roll_result(state: dict, pre_resources: dict | None):
|
|
@@ -652,9 +949,10 @@ def _print_roll_result(state: dict, pre_resources: dict | None):
|
|
|
652
949
|
|
|
653
950
|
|
|
654
951
|
def cmd_act(args):
|
|
655
|
-
game_id =
|
|
656
|
-
|
|
657
|
-
|
|
952
|
+
game_id, token, color = _resolve_session(args.game, args.token, args.color)
|
|
953
|
+
_require("GAME", game_id)
|
|
954
|
+
_require("TOKEN", token)
|
|
955
|
+
_require("COLOR", color)
|
|
658
956
|
|
|
659
957
|
# Snapshot resources before rolling so we can diff afterwards
|
|
660
958
|
pre_resources = None
|
|
@@ -694,7 +992,7 @@ def cmd_act(args):
|
|
|
694
992
|
print(f" Current turn: {current} | Prompt: {prompt}", file=sys.stderr)
|
|
695
993
|
actions = state.get("current_playable_actions", [])
|
|
696
994
|
if actions:
|
|
697
|
-
_print_actions(actions, my_color=color)
|
|
995
|
+
_print_actions(actions, my_color=color, state=state)
|
|
698
996
|
print(
|
|
699
997
|
"\n Tip: run 'clawtan wait' to get a full turn briefing with available actions.",
|
|
700
998
|
file=sys.stderr,
|
|
@@ -739,21 +1037,21 @@ def cmd_act(args):
|
|
|
739
1037
|
_print_resources(my["resources"])
|
|
740
1038
|
|
|
741
1039
|
if my_actions:
|
|
742
|
-
_print_actions(actions, my_color=color)
|
|
1040
|
+
_print_actions(actions, my_color=color, state=state)
|
|
743
1041
|
else:
|
|
744
1042
|
print("\n No actions available.")
|
|
745
1043
|
elif my_actions:
|
|
746
1044
|
# We have actions even though current_color is someone else
|
|
747
1045
|
# (e.g. we also need to discard on a 7)
|
|
748
1046
|
print(f" Prompt: {prompt}")
|
|
749
|
-
_print_actions(actions, my_color=color)
|
|
1047
|
+
_print_actions(actions, my_color=color, state=state)
|
|
750
1048
|
else:
|
|
751
1049
|
print(f"\n Action done. No more actions available. Run 'clawtan wait' for your next turn or action required.")
|
|
752
1050
|
|
|
753
1051
|
|
|
754
1052
|
def cmd_status(args):
|
|
755
|
-
game_id =
|
|
756
|
-
|
|
1053
|
+
game_id, token, _ = _resolve_session(args.game, args.token)
|
|
1054
|
+
_require("GAME", game_id)
|
|
757
1055
|
|
|
758
1056
|
status = _get(f"/game/{game_id}/status", token=token)
|
|
759
1057
|
|
|
@@ -777,7 +1075,8 @@ def cmd_status(args):
|
|
|
777
1075
|
|
|
778
1076
|
|
|
779
1077
|
def cmd_board(args):
|
|
780
|
-
game_id =
|
|
1078
|
+
game_id, _, _ = _resolve_session(args.game)
|
|
1079
|
+
_require("GAME", game_id)
|
|
781
1080
|
state = _get(f"/game/{game_id}")
|
|
782
1081
|
|
|
783
1082
|
if not state.get("started") or not state.get("tiles"):
|
|
@@ -902,19 +1201,36 @@ def cmd_board(args):
|
|
|
902
1201
|
for r in roads:
|
|
903
1202
|
print(f" Edge {r['id']}: {r['color']}")
|
|
904
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
|
+
|
|
905
1219
|
if robber:
|
|
906
|
-
print(f"\n
|
|
1220
|
+
print(f"\n Kraken: {robber}")
|
|
907
1221
|
|
|
908
1222
|
|
|
909
1223
|
def cmd_chat(args):
|
|
910
|
-
game_id =
|
|
911
|
-
|
|
1224
|
+
game_id, token, _ = _resolve_session(args.game, args.token)
|
|
1225
|
+
_require("GAME", game_id)
|
|
1226
|
+
_require("TOKEN", token)
|
|
912
1227
|
_post(f"/game/{game_id}/chat", {"message": args.message}, token=token)
|
|
913
1228
|
print("Chat sent.")
|
|
914
1229
|
|
|
915
1230
|
|
|
916
1231
|
def cmd_chat_read(args):
|
|
917
|
-
game_id =
|
|
1232
|
+
game_id, _, _ = _resolve_session(args.game)
|
|
1233
|
+
_require("GAME", game_id)
|
|
918
1234
|
resp = _get(f"/game/{game_id}/chat?since={args.since}")
|
|
919
1235
|
msgs = resp.get("messages", [])
|
|
920
1236
|
if msgs:
|
|
@@ -939,6 +1255,11 @@ def main():
|
|
|
939
1255
|
),
|
|
940
1256
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
941
1257
|
)
|
|
1258
|
+
parser.add_argument(
|
|
1259
|
+
"--player",
|
|
1260
|
+
metavar="COLOR",
|
|
1261
|
+
help="Player color — selects the per-player session file (for multi-player on one machine)",
|
|
1262
|
+
)
|
|
942
1263
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
943
1264
|
|
|
944
1265
|
# -- create --------------------------------------------------------
|
|
@@ -1014,7 +1335,7 @@ def main():
|
|
|
1014
1335
|
" BUILD_CURRENT <edge> Build road, e.g. '[3,7]'\n"
|
|
1015
1336
|
" BUY_TREASURE_MAP Buy dev card\n"
|
|
1016
1337
|
" SUMMON_LOBSTER_GUARD Play knight card\n"
|
|
1017
|
-
" MOVE_THE_KRAKEN <val> Move
|
|
1338
|
+
" MOVE_THE_KRAKEN <val> Move Kraken, e.g. '[[0,1,-1],\"BLUE\",null]'\n"
|
|
1018
1339
|
" RELEASE_CATCH Discard half your cards (server selects randomly)\n"
|
|
1019
1340
|
" PLAY_BOUNTIFUL_HARVEST <r> Year of Plenty, e.g. '[\"DRIFTWOOD\",\"CORAL\"]'\n"
|
|
1020
1341
|
" PLAY_TIDAL_MONOPOLY <res> Monopoly, e.g. SHRIMP\n"
|
|
@@ -1058,7 +1379,7 @@ def main():
|
|
|
1058
1379
|
help="Show board layout, buildings, and roads",
|
|
1059
1380
|
description=(
|
|
1060
1381
|
"Display the board: tiles with resources/numbers, ports,\n"
|
|
1061
|
-
"buildings, roads, and
|
|
1382
|
+
"buildings, roads, and Kraken location.\n"
|
|
1062
1383
|
"Tile layout is static after game start -- call once and remember it."
|
|
1063
1384
|
),
|
|
1064
1385
|
)
|
|
@@ -1088,6 +1409,8 @@ def main():
|
|
|
1088
1409
|
|
|
1089
1410
|
# -- Parse and run -------------------------------------------------
|
|
1090
1411
|
args = parser.parse_args()
|
|
1412
|
+
if args.player:
|
|
1413
|
+
os.environ["CLAWTAN_COLOR"] = args.player
|
|
1091
1414
|
try:
|
|
1092
1415
|
args.func(args)
|
|
1093
1416
|
except APIError as e:
|