clawtan 0.1.13 → 0.2.1
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 +197 -64
- package/package.json +1 -1
package/clawtan/cli.py
CHANGED
|
@@ -3,22 +3,35 @@
|
|
|
3
3
|
clawtan -- CLI for AI agents playing Settlers of Clawtan.
|
|
4
4
|
|
|
5
5
|
Every command prints structured text to stdout designed for easy scanning
|
|
6
|
-
by LLM agents.
|
|
7
|
-
|
|
8
|
-
CLAWTAN_SERVER Server URL (default: http://localhost:8000)
|
|
9
|
-
CLAWTAN_GAME Game ID
|
|
10
|
-
CLAWTAN_TOKEN Auth token from join
|
|
11
|
-
CLAWTAN_COLOR Your player color
|
|
6
|
+
by LLM agents. Session credentials are saved automatically on join.
|
|
12
7
|
|
|
13
8
|
Typical agent flow:
|
|
14
|
-
clawtan quick-join --name "LobsterBot"
|
|
15
|
-
export CLAWTAN_GAME=... CLAWTAN_TOKEN=... CLAWTAN_COLOR=...
|
|
9
|
+
clawtan quick-join --name "LobsterBot" # session saved to ~/.clawtan_session
|
|
16
10
|
clawtan board # once, to learn the map
|
|
17
11
|
clawtan wait # blocks until your turn
|
|
18
12
|
clawtan act ROLL_THE_SHELLS
|
|
19
13
|
clawtan act BUILD_TIDE_POOL 42
|
|
20
14
|
clawtan act END_TIDE
|
|
21
15
|
clawtan wait # next turn...
|
|
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
|
+
|
|
29
|
+
Session lookup order (per field):
|
|
30
|
+
1. CLI flags (--game, --token, --color, --player)
|
|
31
|
+
2. Environment variables (CLAWTAN_GAME, CLAWTAN_TOKEN, CLAWTAN_COLOR)
|
|
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.
|
|
22
35
|
"""
|
|
23
36
|
|
|
24
37
|
import argparse
|
|
@@ -125,13 +138,102 @@ def _get(path, token=None):
|
|
|
125
138
|
|
|
126
139
|
|
|
127
140
|
# ---------------------------------------------------------------------------
|
|
128
|
-
#
|
|
141
|
+
# Session file helpers
|
|
129
142
|
# ---------------------------------------------------------------------------
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
143
|
+
_SESSIONS_DIR = os.path.expanduser("~/.clawtan_sessions")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _save_session(game_id: str, token: str, color: str):
|
|
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")
|
|
150
|
+
try:
|
|
151
|
+
with open(path, "w") as f:
|
|
152
|
+
json.dump(data, f)
|
|
153
|
+
except OSError as e:
|
|
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
|
|
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]
|
|
200
|
+
|
|
201
|
+
default = os.path.expanduser("~/.clawtan_session")
|
|
202
|
+
try:
|
|
203
|
+
with open(default) as f:
|
|
204
|
+
return json.load(f)
|
|
205
|
+
except (OSError, json.JSONDecodeError):
|
|
206
|
+
return {}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# ---------------------------------------------------------------------------
|
|
210
|
+
# Session resolution
|
|
211
|
+
# ---------------------------------------------------------------------------
|
|
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."""
|
|
233
|
+
if not val:
|
|
133
234
|
print(
|
|
134
|
-
f"ERROR: Missing {name}. Pass --{name.lower()}
|
|
235
|
+
f"ERROR: Missing {name}. Pass --{name.lower()}, set CLAWTAN_{name},"
|
|
236
|
+
f" or run 'clawtan quick-join' to create a session.",
|
|
135
237
|
file=sys.stderr,
|
|
136
238
|
)
|
|
137
239
|
sys.exit(1)
|
|
@@ -290,10 +392,8 @@ def _print_opponents(opponents: list):
|
|
|
290
392
|
|
|
291
393
|
_ACTION_HINTS = {
|
|
292
394
|
"RELEASE_CATCH": (
|
|
293
|
-
"Discard
|
|
294
|
-
" CLI: clawtan act RELEASE_CATCH
|
|
295
|
-
" Or pick specific cards (freqdeck=[DRIFTWOOD,CORAL,SHRIMP,KELP,PEARL]):\n"
|
|
296
|
-
" CLI: clawtan act RELEASE_CATCH '[1,0,0,1,0]'"
|
|
395
|
+
"Discard half your cards (server selects randomly).\n"
|
|
396
|
+
" CLI: clawtan act RELEASE_CATCH"
|
|
297
397
|
),
|
|
298
398
|
"MOVE_THE_KRAKEN": (
|
|
299
399
|
"Move robber: value = [coordinate, victim_color_or_null, null].\n"
|
|
@@ -360,17 +460,34 @@ def _print_actions(actions: list, my_color: str | None = None):
|
|
|
360
460
|
print(f"\n (other players still need to act: {', '.join(sorted(other_colors))})")
|
|
361
461
|
|
|
362
462
|
|
|
463
|
+
def _unpack_record(r):
|
|
464
|
+
"""Unpack an action record into (color, action_type, value).
|
|
465
|
+
|
|
466
|
+
Records may be nested [[color, type, value], result] or flat [color, type, value].
|
|
467
|
+
"""
|
|
468
|
+
if isinstance(r, list) and len(r) >= 2 and isinstance(r[0], list):
|
|
469
|
+
action = r[0]
|
|
470
|
+
color = action[0] if len(action) > 0 else None
|
|
471
|
+
atype = action[1] if len(action) > 1 else None
|
|
472
|
+
val = action[2] if len(action) > 2 else None
|
|
473
|
+
return color, atype, val
|
|
474
|
+
if isinstance(r, list) and len(r) >= 2:
|
|
475
|
+
color = r[0]
|
|
476
|
+
atype = r[1]
|
|
477
|
+
val = r[2] if len(r) > 2 else None
|
|
478
|
+
return color, atype, val
|
|
479
|
+
return None, None, None
|
|
480
|
+
|
|
481
|
+
|
|
363
482
|
def _print_history(records: list, since: int = 0):
|
|
364
483
|
recent = records[since:]
|
|
365
484
|
if not recent:
|
|
366
485
|
return
|
|
367
486
|
_section(f"Recent Actions ({len(recent)} moves)")
|
|
368
487
|
for r in recent:
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
val = r[2] if len(r) > 2 and r[2] is not None else ""
|
|
373
|
-
if val != "":
|
|
488
|
+
color, action, val = _unpack_record(r)
|
|
489
|
+
if color and action:
|
|
490
|
+
if val is not None:
|
|
374
491
|
print(f" {color}: {action} {json.dumps(val, separators=(',', ':'))}")
|
|
375
492
|
else:
|
|
376
493
|
print(f" {color}: {action}")
|
|
@@ -378,6 +495,25 @@ def _print_history(records: list, since: int = 0):
|
|
|
378
495
|
print(f" {r}")
|
|
379
496
|
|
|
380
497
|
|
|
498
|
+
def _count_turns(state: dict) -> int:
|
|
499
|
+
"""Count ROLL_THE_SHELLS records to get the turn number."""
|
|
500
|
+
count = 0
|
|
501
|
+
for r in state.get("action_records", []):
|
|
502
|
+
_, atype, _ = _unpack_record(r)
|
|
503
|
+
if atype == "ROLL_THE_SHELLS":
|
|
504
|
+
count += 1
|
|
505
|
+
return count
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _who_rolled_last(state: dict) -> str | None:
|
|
509
|
+
"""Return the color of the player who made the most recent roll."""
|
|
510
|
+
for r in reversed(state.get("action_records", [])):
|
|
511
|
+
color, atype, _ = _unpack_record(r)
|
|
512
|
+
if atype == "ROLL_THE_SHELLS":
|
|
513
|
+
return color
|
|
514
|
+
return None
|
|
515
|
+
|
|
516
|
+
|
|
381
517
|
def _print_chat(messages: list, label: str = "Chat"):
|
|
382
518
|
if not messages:
|
|
383
519
|
return
|
|
@@ -424,16 +560,22 @@ def _print_join(resp: dict):
|
|
|
424
560
|
print(f" Seat: {resp['seat_index']}")
|
|
425
561
|
print(f" Players: {resp['players_joined']}")
|
|
426
562
|
print(f" Started: {'yes' if resp.get('game_started') else 'no'}")
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
563
|
+
|
|
564
|
+
_save_session(resp["game_id"], resp["token"], resp["player_color"])
|
|
565
|
+
gid = resp["game_id"]
|
|
566
|
+
color = resp["player_color"]
|
|
567
|
+
print(f"\n Session saved to ~/.clawtan_sessions/{gid}_{color}.json")
|
|
568
|
+
print(f"\n Multi-player setup — pick ONE option:")
|
|
569
|
+
print(f" export CLAWTAN_COLOR={color} # env var per terminal")
|
|
570
|
+
print(f" clawtan --player {color} <command> # flag per command")
|
|
571
|
+
print(f" clawtan --player {color} wait --game {gid} # if same color in multiple games")
|
|
431
572
|
|
|
432
573
|
|
|
433
574
|
def cmd_wait(args):
|
|
434
|
-
game_id =
|
|
435
|
-
|
|
436
|
-
|
|
575
|
+
game_id, token, color = _resolve_session(args.game, args.token, args.color)
|
|
576
|
+
_require("GAME", game_id)
|
|
577
|
+
_require("TOKEN", token)
|
|
578
|
+
_require("COLOR", color)
|
|
437
579
|
poll = args.poll
|
|
438
580
|
deadline = time.monotonic() + args.timeout
|
|
439
581
|
|
|
@@ -510,7 +652,7 @@ def cmd_wait(args):
|
|
|
510
652
|
state = _get(f"/game/{game_id}")
|
|
511
653
|
|
|
512
654
|
prompt = state.get("current_prompt", "?")
|
|
513
|
-
turns =
|
|
655
|
+
turns = status.get("num_turns") or state.get("num_turns") or _count_turns(state)
|
|
514
656
|
|
|
515
657
|
_header("YOUR TURN")
|
|
516
658
|
print(f" Game: {game_id}")
|
|
@@ -551,8 +693,9 @@ def _print_roll_result(state: dict, pre_resources: dict | None):
|
|
|
551
693
|
records = state.get("action_records", [])
|
|
552
694
|
roll_val = None
|
|
553
695
|
for r in reversed(records):
|
|
554
|
-
|
|
555
|
-
|
|
696
|
+
color, atype, val = _unpack_record(r)
|
|
697
|
+
if atype == "ROLL_THE_SHELLS":
|
|
698
|
+
roll_val = val
|
|
556
699
|
break
|
|
557
700
|
|
|
558
701
|
if roll_val is not None:
|
|
@@ -584,9 +727,10 @@ def _print_roll_result(state: dict, pre_resources: dict | None):
|
|
|
584
727
|
|
|
585
728
|
|
|
586
729
|
def cmd_act(args):
|
|
587
|
-
game_id =
|
|
588
|
-
|
|
589
|
-
|
|
730
|
+
game_id, token, color = _resolve_session(args.game, args.token, args.color)
|
|
731
|
+
_require("GAME", game_id)
|
|
732
|
+
_require("TOKEN", token)
|
|
733
|
+
_require("COLOR", color)
|
|
590
734
|
|
|
591
735
|
# Snapshot resources before rolling so we can diff afterwards
|
|
592
736
|
pre_resources = None
|
|
@@ -679,34 +823,13 @@ def cmd_act(args):
|
|
|
679
823
|
# (e.g. we also need to discard on a 7)
|
|
680
824
|
print(f" Prompt: {prompt}")
|
|
681
825
|
_print_actions(actions, my_color=color)
|
|
682
|
-
print(
|
|
683
|
-
f"\n Note: {current_color} is also acting (e.g. discarding)."
|
|
684
|
-
f" Your turn will continue after -- run 'clawtan wait'.",
|
|
685
|
-
)
|
|
686
|
-
elif prompt in ("RELEASE_CATCH", "MOVE_THE_KRAKEN", "DISCARD"):
|
|
687
|
-
# Discard/robber phase -- other players are acting but our turn resumes after
|
|
688
|
-
_section("Waiting on Other Players")
|
|
689
|
-
# Figure out which players need to act
|
|
690
|
-
other_colors = set()
|
|
691
|
-
for a in actions:
|
|
692
|
-
if isinstance(a, list) and len(a) > 1 and a[0] and a[0] != color:
|
|
693
|
-
other_colors.add(a[0])
|
|
694
|
-
if other_colors:
|
|
695
|
-
print(f" {', '.join(sorted(other_colors))} must {prompt.lower().replace('_', ' ')} first.")
|
|
696
|
-
else:
|
|
697
|
-
print(f" Current prompt: {prompt} (waiting on {current_color})")
|
|
698
|
-
print(
|
|
699
|
-
f"\n YOUR TURN IS NOT OVER. After they finish, you will continue"
|
|
700
|
-
f" (e.g. move the Kraken, then play your turn)."
|
|
701
|
-
f"\n Run 'clawtan wait' to resume."
|
|
702
|
-
)
|
|
703
826
|
else:
|
|
704
|
-
print(f"\n
|
|
827
|
+
print(f"\n Action done. No more actions available. Run 'clawtan wait' for your next turn or action required.")
|
|
705
828
|
|
|
706
829
|
|
|
707
830
|
def cmd_status(args):
|
|
708
|
-
game_id =
|
|
709
|
-
|
|
831
|
+
game_id, token, _ = _resolve_session(args.game, args.token)
|
|
832
|
+
_require("GAME", game_id)
|
|
710
833
|
|
|
711
834
|
status = _get(f"/game/{game_id}/status", token=token)
|
|
712
835
|
|
|
@@ -730,7 +853,8 @@ def cmd_status(args):
|
|
|
730
853
|
|
|
731
854
|
|
|
732
855
|
def cmd_board(args):
|
|
733
|
-
game_id =
|
|
856
|
+
game_id, _, _ = _resolve_session(args.game)
|
|
857
|
+
_require("GAME", game_id)
|
|
734
858
|
state = _get(f"/game/{game_id}")
|
|
735
859
|
|
|
736
860
|
if not state.get("started") or not state.get("tiles"):
|
|
@@ -860,14 +984,16 @@ def cmd_board(args):
|
|
|
860
984
|
|
|
861
985
|
|
|
862
986
|
def cmd_chat(args):
|
|
863
|
-
game_id =
|
|
864
|
-
|
|
987
|
+
game_id, token, _ = _resolve_session(args.game, args.token)
|
|
988
|
+
_require("GAME", game_id)
|
|
989
|
+
_require("TOKEN", token)
|
|
865
990
|
_post(f"/game/{game_id}/chat", {"message": args.message}, token=token)
|
|
866
991
|
print("Chat sent.")
|
|
867
992
|
|
|
868
993
|
|
|
869
994
|
def cmd_chat_read(args):
|
|
870
|
-
game_id =
|
|
995
|
+
game_id, _, _ = _resolve_session(args.game)
|
|
996
|
+
_require("GAME", game_id)
|
|
871
997
|
resp = _get(f"/game/{game_id}/chat?since={args.since}")
|
|
872
998
|
msgs = resp.get("messages", [])
|
|
873
999
|
if msgs:
|
|
@@ -892,6 +1018,11 @@ def main():
|
|
|
892
1018
|
),
|
|
893
1019
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
894
1020
|
)
|
|
1021
|
+
parser.add_argument(
|
|
1022
|
+
"--player",
|
|
1023
|
+
metavar="COLOR",
|
|
1024
|
+
help="Player color — selects the per-player session file (for multi-player on one machine)",
|
|
1025
|
+
)
|
|
895
1026
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
896
1027
|
|
|
897
1028
|
# -- create --------------------------------------------------------
|
|
@@ -968,7 +1099,7 @@ def main():
|
|
|
968
1099
|
" BUY_TREASURE_MAP Buy dev card\n"
|
|
969
1100
|
" SUMMON_LOBSTER_GUARD Play knight card\n"
|
|
970
1101
|
" MOVE_THE_KRAKEN <val> Move robber, e.g. '[[0,1,-1],\"BLUE\",null]'\n"
|
|
971
|
-
" RELEASE_CATCH
|
|
1102
|
+
" RELEASE_CATCH Discard half your cards (server selects randomly)\n"
|
|
972
1103
|
" PLAY_BOUNTIFUL_HARVEST <r> Year of Plenty, e.g. '[\"DRIFTWOOD\",\"CORAL\"]'\n"
|
|
973
1104
|
" PLAY_TIDAL_MONOPOLY <res> Monopoly, e.g. SHRIMP\n"
|
|
974
1105
|
" PLAY_CURRENT_BUILDING Road Building\n"
|
|
@@ -1041,6 +1172,8 @@ def main():
|
|
|
1041
1172
|
|
|
1042
1173
|
# -- Parse and run -------------------------------------------------
|
|
1043
1174
|
args = parser.parse_args()
|
|
1175
|
+
if args.player:
|
|
1176
|
+
os.environ["CLAWTAN_COLOR"] = args.player
|
|
1044
1177
|
try:
|
|
1045
1178
|
args.func(args)
|
|
1046
1179
|
except APIError as e:
|