fcode 2.2.0__cp313-cp313-win_amd64.whl

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 (245) hide show
  1. fcode/__init__.py +3 -0
  2. fcode/_types.py +655 -0
  3. fcode/api.py +146 -0
  4. fcode/auth.py +49 -0
  5. fcode/cli.py +127 -0
  6. fcode/commands/__init__.py +0 -0
  7. fcode/commands/init.py +29 -0
  8. fcode/commands/ladder.py +74 -0
  9. fcode/commands/login.py +135 -0
  10. fcode/commands/logout.py +21 -0
  11. fcode/commands/map_editor.py +91 -0
  12. fcode/commands/match_detail.py +173 -0
  13. fcode/commands/match_group.py +163 -0
  14. fcode/commands/matches.py +127 -0
  15. fcode/commands/run.py +186 -0
  16. fcode/commands/starter.py +119 -0
  17. fcode/commands/status.py +112 -0
  18. fcode/commands/submission.py +260 -0
  19. fcode/commands/team_group.py +73 -0
  20. fcode/commands/teams.py +73 -0
  21. fcode/commands/test.py +51 -0
  22. fcode/commands/test_matches.py +59 -0
  23. fcode/commands/test_run.py +125 -0
  24. fcode/commands/watch.py +123 -0
  25. fcode/compat.py +36 -0
  26. fcode/config.py +47 -0
  27. fcode/data/__init__.py +0 -0
  28. fcode/data/docs/spec.md +290 -0
  29. fcode/data/maps/__init__.py +0 -0
  30. fcode/data/maps/atoll.map26 +0 -0
  31. fcode/data/maps/aurora.map26 +0 -0
  32. fcode/data/maps/crossfire.map26 +0 -0
  33. fcode/data/maps/duel.map26 +0 -0
  34. fcode/data/maps/fjord.map26 +0 -0
  35. fcode/data/maps/hive.map26 +0 -0
  36. fcode/data/maps/longship.map26 +0 -0
  37. fcode/data/maps/pinch.map26 +0 -0
  38. fcode/data/maps/quarry.map26 +0 -0
  39. fcode/data/maps/runestone.map26 +0 -0
  40. fcode/data/maps/skerry.map26 +0 -0
  41. fcode/data/maps/sprint.map26 +0 -0
  42. fcode/data/maps/strait.map26 +0 -0
  43. fcode/data/maps/twins.map26 +0 -0
  44. fcode/data/maps/vault.map26 +0 -0
  45. fcode/data/starter_bot.py +452 -0
  46. fcode/data/visualiser/.directory +9 -0
  47. fcode/data/visualiser/Builderbot_med.png +0 -0
  48. fcode/data/visualiser/armoured_conveyor_gold.png +0 -0
  49. fcode/data/visualiser/armoured_conveyor_silver.png +0 -0
  50. fcode/data/visualiser/assets/app-BiRa_5u6.css +1 -0
  51. fcode/data/visualiser/assets/app-CK52NxpR.js +86 -0
  52. fcode/data/visualiser/assets/main-COIx42iq.js +1 -0
  53. fcode/data/visualiser/assets/map-editor-BDLYjgel.js +1 -0
  54. fcode/data/visualiser/axionite_ore.png +0 -0
  55. fcode/data/visualiser/axionite_processed.png +0 -0
  56. fcode/data/visualiser/axionite_raw.png +0 -0
  57. fcode/data/visualiser/barrier_gold.png +0 -0
  58. fcode/data/visualiser/barrier_silver.png +0 -0
  59. fcode/data/visualiser/base_gold.png +0 -0
  60. fcode/data/visualiser/base_silver.png +0 -0
  61. fcode/data/visualiser/battlecodetitle-orange.webp +0 -0
  62. fcode/data/visualiser/bg-light.png +0 -0
  63. fcode/data/visualiser/bg.png +0 -0
  64. fcode/data/visualiser/breach_e_gold.png +0 -0
  65. fcode/data/visualiser/breach_e_silver.png +0 -0
  66. fcode/data/visualiser/breach_n_gold.png +0 -0
  67. fcode/data/visualiser/breach_n_silver.png +0 -0
  68. fcode/data/visualiser/breach_ne_gold.png +0 -0
  69. fcode/data/visualiser/breach_ne_silver.png +0 -0
  70. fcode/data/visualiser/breach_nw_gold.png +0 -0
  71. fcode/data/visualiser/breach_nw_silver.png +0 -0
  72. fcode/data/visualiser/breach_s_gold.png +0 -0
  73. fcode/data/visualiser/breach_s_silver.png +0 -0
  74. fcode/data/visualiser/breach_se_gold.png +0 -0
  75. fcode/data/visualiser/breach_se_silver.png +0 -0
  76. fcode/data/visualiser/breach_sw_gold.png +0 -0
  77. fcode/data/visualiser/breach_sw_silver.png +0 -0
  78. fcode/data/visualiser/bridge_gold.png +0 -0
  79. fcode/data/visualiser/bridge_silver.png +0 -0
  80. fcode/data/visualiser/bridge_stand_gold.png +0 -0
  81. fcode/data/visualiser/bridge_stand_silver.png +0 -0
  82. fcode/data/visualiser/builderbot_back_gold.png +0 -0
  83. fcode/data/visualiser/builderbot_back_silver.png +0 -0
  84. fcode/data/visualiser/builderbot_front_gold.png +0 -0
  85. fcode/data/visualiser/builderbot_front_silver.png +0 -0
  86. fcode/data/visualiser/builderbot_side_gold.png +0 -0
  87. fcode/data/visualiser/builderbot_side_silver.png +0 -0
  88. fcode/data/visualiser/conveyor_gold.png +0 -0
  89. fcode/data/visualiser/conveyor_silver.png +0 -0
  90. fcode/data/visualiser/dimetric/Dimetric_Tile_Cube.png +0 -0
  91. fcode/data/visualiser/dimetric/Dimetric_Tile_Floor_Footprint.png +0 -0
  92. fcode/data/visualiser/dimetric/barrier_team1.png +0 -0
  93. fcode/data/visualiser/dimetric/barrier_team2.png +0 -0
  94. fcode/data/visualiser/dimetric/base_team1.png +0 -0
  95. fcode/data/visualiser/dimetric/base_team2.png +0 -0
  96. fcode/data/visualiser/dimetric/bg.png +0 -0
  97. fcode/data/visualiser/dimetric/bridge_stand_team1.png +0 -0
  98. fcode/data/visualiser/dimetric/bridge_stand_team2.png +0 -0
  99. fcode/data/visualiser/dimetric/bridge_team1.png +0 -0
  100. fcode/data/visualiser/dimetric/bridge_team2.png +0 -0
  101. fcode/data/visualiser/dimetric/builderbot_e_team1.png +0 -0
  102. fcode/data/visualiser/dimetric/builderbot_e_team2.png +0 -0
  103. fcode/data/visualiser/dimetric/builderbot_n_team1.png +0 -0
  104. fcode/data/visualiser/dimetric/builderbot_n_team2.png +0 -0
  105. fcode/data/visualiser/dimetric/builderbot_ne_team1.png +0 -0
  106. fcode/data/visualiser/dimetric/builderbot_ne_team2.png +0 -0
  107. fcode/data/visualiser/dimetric/builderbot_nw_team1.png +0 -0
  108. fcode/data/visualiser/dimetric/builderbot_nw_team2.png +0 -0
  109. fcode/data/visualiser/dimetric/builderbot_s_team1.png +0 -0
  110. fcode/data/visualiser/dimetric/builderbot_s_team2.png +0 -0
  111. fcode/data/visualiser/dimetric/builderbot_se_team1.png +0 -0
  112. fcode/data/visualiser/dimetric/builderbot_se_team2.png +0 -0
  113. fcode/data/visualiser/dimetric/builderbot_sw_team1.png +0 -0
  114. fcode/data/visualiser/dimetric/builderbot_sw_team2.png +0 -0
  115. fcode/data/visualiser/dimetric/builderbot_w_team1.png +0 -0
  116. fcode/data/visualiser/dimetric/builderbot_w_team2.png +0 -0
  117. fcode/data/visualiser/dimetric/conveyor_ne_team1.png +0 -0
  118. fcode/data/visualiser/dimetric/conveyor_ne_team2.png +0 -0
  119. fcode/data/visualiser/dimetric/conveyor_nw_team1.png +0 -0
  120. fcode/data/visualiser/dimetric/conveyor_nw_team2.png +0 -0
  121. fcode/data/visualiser/dimetric/conveyor_se_team1.png +0 -0
  122. fcode/data/visualiser/dimetric/conveyor_se_team2.png +0 -0
  123. fcode/data/visualiser/dimetric/conveyor_sw_team1.png +0 -0
  124. fcode/data/visualiser/dimetric/conveyor_sw_team2.png +0 -0
  125. fcode/data/visualiser/dimetric/gunner_e_team1.png +0 -0
  126. fcode/data/visualiser/dimetric/gunner_e_team2.png +0 -0
  127. fcode/data/visualiser/dimetric/gunner_n_team1.png +0 -0
  128. fcode/data/visualiser/dimetric/gunner_n_team2.png +0 -0
  129. fcode/data/visualiser/dimetric/gunner_ne_team1.png +0 -0
  130. fcode/data/visualiser/dimetric/gunner_ne_team2.png +0 -0
  131. fcode/data/visualiser/dimetric/gunner_nw_team1.png +0 -0
  132. fcode/data/visualiser/dimetric/gunner_nw_team2.png +0 -0
  133. fcode/data/visualiser/dimetric/gunner_s_team1.png +0 -0
  134. fcode/data/visualiser/dimetric/gunner_s_team2.png +0 -0
  135. fcode/data/visualiser/dimetric/gunner_se_team1.png +0 -0
  136. fcode/data/visualiser/dimetric/gunner_se_team2.png +0 -0
  137. fcode/data/visualiser/dimetric/gunner_sw_team1.png +0 -0
  138. fcode/data/visualiser/dimetric/gunner_sw_team2.png +0 -0
  139. fcode/data/visualiser/dimetric/gunner_w_team1.png +0 -0
  140. fcode/data/visualiser/dimetric/gunner_w_team2.png +0 -0
  141. fcode/data/visualiser/dimetric/harvester_team1.png +0 -0
  142. fcode/data/visualiser/dimetric/harvester_team2.png +0 -0
  143. fcode/data/visualiser/dimetric/launcher_team1_ne.png +0 -0
  144. fcode/data/visualiser/dimetric/launcher_team1_nw.png +0 -0
  145. fcode/data/visualiser/dimetric/launcher_team1_se.png +0 -0
  146. fcode/data/visualiser/dimetric/launcher_team1_sw.png +0 -0
  147. fcode/data/visualiser/dimetric/launcher_team2_ne.png +0 -0
  148. fcode/data/visualiser/dimetric/launcher_team2_nw.png +0 -0
  149. fcode/data/visualiser/dimetric/launcher_team2_se.png +0 -0
  150. fcode/data/visualiser/dimetric/launcher_team2_sw.png +0 -0
  151. fcode/data/visualiser/dimetric/natural_wall.png +0 -0
  152. fcode/data/visualiser/dimetric/natural_wall_ne.png +0 -0
  153. fcode/data/visualiser/dimetric/natural_wall_nw.png +0 -0
  154. fcode/data/visualiser/dimetric/natural_wall_se.png +0 -0
  155. fcode/data/visualiser/dimetric/natural_wall_sw.png +0 -0
  156. fcode/data/visualiser/dimetric/road_team1.png +0 -0
  157. fcode/data/visualiser/dimetric/road_team2.png +0 -0
  158. fcode/data/visualiser/dimetric/sentinel_e_team1.png +0 -0
  159. fcode/data/visualiser/dimetric/sentinel_e_team2.png +0 -0
  160. fcode/data/visualiser/dimetric/sentinel_n_team1.png +0 -0
  161. fcode/data/visualiser/dimetric/sentinel_n_team2.png +0 -0
  162. fcode/data/visualiser/dimetric/sentinel_ne_team1.png +0 -0
  163. fcode/data/visualiser/dimetric/sentinel_ne_team2.png +0 -0
  164. fcode/data/visualiser/dimetric/sentinel_nw_team1.png +0 -0
  165. fcode/data/visualiser/dimetric/sentinel_nw_team2.png +0 -0
  166. fcode/data/visualiser/dimetric/sentinel_s_team1.png +0 -0
  167. fcode/data/visualiser/dimetric/sentinel_s_team2.png +0 -0
  168. fcode/data/visualiser/dimetric/sentinel_se_team1.png +0 -0
  169. fcode/data/visualiser/dimetric/sentinel_se_team2.png +0 -0
  170. fcode/data/visualiser/dimetric/sentinel_sw_team1.png +0 -0
  171. fcode/data/visualiser/dimetric/sentinel_sw_team2.png +0 -0
  172. fcode/data/visualiser/dimetric/sentinel_w_team1.png +0 -0
  173. fcode/data/visualiser/dimetric/sentinel_w_team2.png +0 -0
  174. fcode/data/visualiser/dimetric/splitter_ne_team1.png +0 -0
  175. fcode/data/visualiser/dimetric/splitter_ne_team2.png +0 -0
  176. fcode/data/visualiser/dimetric/splitter_nw_team1.png +0 -0
  177. fcode/data/visualiser/dimetric/splitter_nw_team2.png +0 -0
  178. fcode/data/visualiser/dimetric/splitter_se_team1.png +0 -0
  179. fcode/data/visualiser/dimetric/splitter_se_team2.png +0 -0
  180. fcode/data/visualiser/dimetric/splitter_sw_team1.png +0 -0
  181. fcode/data/visualiser/dimetric/splitter_sw_team2.png +0 -0
  182. fcode/data/visualiser/dimetric/titanium.png +0 -0
  183. fcode/data/visualiser/dimetric/titanium_ore.png +0 -0
  184. fcode/data/visualiser/fonts/HKGrotesk-Regular.otf +0 -0
  185. fcode/data/visualiser/foundry_gold.png +0 -0
  186. fcode/data/visualiser/foundry_silver.png +0 -0
  187. fcode/data/visualiser/gunner_n_gold.png +0 -0
  188. fcode/data/visualiser/gunner_n_silver.png +0 -0
  189. fcode/data/visualiser/gunner_ne_gold.png +0 -0
  190. fcode/data/visualiser/gunner_ne_silver.png +0 -0
  191. fcode/data/visualiser/gunner_nw_gold.png +0 -0
  192. fcode/data/visualiser/gunner_nw_silver.png +0 -0
  193. fcode/data/visualiser/gunner_s_gold.png +0 -0
  194. fcode/data/visualiser/gunner_s_silver.png +0 -0
  195. fcode/data/visualiser/gunner_se_gold.png +0 -0
  196. fcode/data/visualiser/gunner_se_silver.png +0 -0
  197. fcode/data/visualiser/gunner_sw_gold.png +0 -0
  198. fcode/data/visualiser/gunner_sw_silver.png +0 -0
  199. fcode/data/visualiser/gunner_w_gold.png +0 -0
  200. fcode/data/visualiser/gunner_w_silver.png +0 -0
  201. fcode/data/visualiser/harvester_gold.png +0 -0
  202. fcode/data/visualiser/harvester_silver.png +0 -0
  203. fcode/data/visualiser/index.html +20 -0
  204. fcode/data/visualiser/launcher_gold.png +0 -0
  205. fcode/data/visualiser/launcher_silver.png +0 -0
  206. fcode/data/visualiser/map-editor.html +20 -0
  207. fcode/data/visualiser/marker_gold.png +0 -0
  208. fcode/data/visualiser/marker_silver.png +0 -0
  209. fcode/data/visualiser/natural_wall.jpg +0 -0
  210. fcode/data/visualiser/road_gold.png +0 -0
  211. fcode/data/visualiser/road_silver.png +0 -0
  212. fcode/data/visualiser/sentinel_e_gold.png +0 -0
  213. fcode/data/visualiser/sentinel_e_silver.png +0 -0
  214. fcode/data/visualiser/sentinel_n_gold.png +0 -0
  215. fcode/data/visualiser/sentinel_n_silver.png +0 -0
  216. fcode/data/visualiser/sentinel_ne_gold.png +0 -0
  217. fcode/data/visualiser/sentinel_ne_silver.png +0 -0
  218. fcode/data/visualiser/sentinel_nw_gold.png +0 -0
  219. fcode/data/visualiser/sentinel_nw_silver.png +0 -0
  220. fcode/data/visualiser/sentinel_s_gold.png +0 -0
  221. fcode/data/visualiser/sentinel_s_silver.png +0 -0
  222. fcode/data/visualiser/sentinel_se_gold.png +0 -0
  223. fcode/data/visualiser/sentinel_se_silver.png +0 -0
  224. fcode/data/visualiser/sentinel_sw_gold.png +0 -0
  225. fcode/data/visualiser/sentinel_sw_silver.png +0 -0
  226. fcode/data/visualiser/splitter.png +0 -0
  227. fcode/data/visualiser/splitter_e_gold.png +0 -0
  228. fcode/data/visualiser/splitter_e_silver.png +0 -0
  229. fcode/data/visualiser/splitter_n_gold.png +0 -0
  230. fcode/data/visualiser/splitter_n_silver.png +0 -0
  231. fcode/data/visualiser/splitter_s_gold.png +0 -0
  232. fcode/data/visualiser/splitter_s_silver.png +0 -0
  233. fcode/data/visualiser/splitter_w_gold.png +0 -0
  234. fcode/data/visualiser/splitter_w_silver.png +0 -0
  235. fcode/data/visualiser/template02.png +0 -0
  236. fcode/data/visualiser/titanium.png +0 -0
  237. fcode/data/visualiser/titanium_ore.png +0 -0
  238. fcode/fcode_engine.cp313-win_amd64.pyd +0 -0
  239. fcode/py.typed +0 -0
  240. fcode/version_check.py +72 -0
  241. fcode-2.2.0.dist-info/METADATA +7 -0
  242. fcode-2.2.0.dist-info/RECORD +245 -0
  243. fcode-2.2.0.dist-info/WHEEL +4 -0
  244. fcode-2.2.0.dist-info/entry_points.txt +2 -0
  245. fcode-2.2.0.dist-info/sboms/fcode_engine.cyclonedx.json +1963 -0
@@ -0,0 +1,173 @@
1
+ """fcode match — view detailed match info including individual games."""
2
+
3
+ import click
4
+ from rich.console import Console
5
+ from rich.table import Table
6
+
7
+ from fcode.api import api_get
8
+
9
+ console = Console()
10
+
11
+ _STAGE_LABELS = {
12
+ "starting": "Starting...",
13
+ "downloading_bots": "Downloading bots...",
14
+ "uploading_replay": "Uploading replay...",
15
+ "processing_result": "Processing...",
16
+ }
17
+
18
+ _CONDITION_LABELS = {
19
+ "core_destroyed": "Core Destroyed",
20
+ "resigned": "Resigned",
21
+ "resources": "Resource Victory",
22
+ "titanium_collected": "Titanium Collected",
23
+ "harvesters": "Harvesters",
24
+ "titanium_stored": "Titanium Stored",
25
+ "coinflip": "Coinflip",
26
+ "timeout": "Timeout",
27
+ "error": "Error",
28
+ }
29
+
30
+
31
+ def _format_stage(stage: str) -> str:
32
+ if stage in _STAGE_LABELS:
33
+ return _STAGE_LABELS[stage]
34
+ if stage.startswith("running_game_"):
35
+ n = stage.split("_")[2]
36
+ return f"Game {n}/5"
37
+ return stage
38
+
39
+
40
+ def _show_match_detail(match_id: str):
41
+ """Core implementation for showing match detail."""
42
+ data = api_get(f"/api/matches/{match_id}")
43
+ m = data.get("match")
44
+ games = data.get("games", [])
45
+
46
+ if not m:
47
+ console.print("[red]Match not found.[/red]")
48
+ raise SystemExit(1)
49
+
50
+ status = m.get("status", "?")
51
+ triggered = m.get("triggeredBy", "?")
52
+ rated = "rated" if m.get("rated") else "unrated"
53
+ score = f"{m.get('scoreA', 0)}-{m.get('scoreB', 0)}"
54
+
55
+ console.print(f"\n[bold]Match {match_id}[/bold]")
56
+
57
+ stage = m.get("stage") or ""
58
+ if status == "complete":
59
+ status_display = f"[green]{status}[/green]"
60
+ elif status == "error":
61
+ status_display = f"[red]{status}[/red]"
62
+ elif status == "running":
63
+ stage_label = _format_stage(stage) if stage else ""
64
+ status_display = f"[yellow]{status}[/yellow]"
65
+ if stage_label:
66
+ status_display += f" ({stage_label})"
67
+ elif status == "queued":
68
+ status_display = f"[dim]{status}[/dim]"
69
+ else:
70
+ status_display = status
71
+ console.print(f" Status: {status_display}")
72
+ console.print(f" Type: {triggered} ({rated})")
73
+
74
+ team_a = m.get("teamAName") or "Bot A"
75
+ team_b = m.get("teamBName") or "Bot B"
76
+ team_a_id = m.get("teamAId")
77
+ team_b_id = m.get("teamBId")
78
+ winner_id = m.get("winnerId")
79
+
80
+ score_a = m.get("scoreA", 0)
81
+ score_b = m.get("scoreB", 0)
82
+ a_is_winner = (winner_id and winner_id == team_a_id) or (not winner_id and score_a > score_b)
83
+ b_is_winner = (winner_id and winner_id == team_b_id) or (not winner_id and score_b > score_a)
84
+
85
+ a_id_display = f" ({team_a_id})" if team_a_id else ""
86
+ a_line = f" Team A: [bold]{team_a}[/bold]{a_id_display}"
87
+ if a_is_winner:
88
+ a_line += " [green]<- winner[/green]"
89
+ source_a = m.get("sourceMatchAId")
90
+ if source_a:
91
+ a_line += f"\n [dim]submission from match {source_a}[/dim]"
92
+
93
+ b_id_display = f" ({team_b_id})" if team_b_id else ""
94
+ b_line = f" Team B: [bold]{team_b}[/bold]{b_id_display}"
95
+ if b_is_winner:
96
+ b_line += " [green]<- winner[/green]"
97
+ source_b = m.get("sourceMatchBId")
98
+ if source_b:
99
+ b_line += f"\n [dim]submission from match {source_b}[/dim]"
100
+
101
+ console.print(a_line)
102
+ console.print(b_line)
103
+ console.print(f" Score: [bold]{score}[/bold]")
104
+
105
+ elo_a = m.get("eloDeltaA")
106
+ elo_b = m.get("eloDeltaB")
107
+ if elo_a is not None and elo_b is not None:
108
+ sign_a = "+" if elo_a >= 0 else ""
109
+ sign_b = "+" if elo_b >= 0 else ""
110
+ console.print(f" ELO: A: {sign_a}{elo_a:.1f} B: {sign_b}{elo_b:.1f}")
111
+
112
+ created = m.get("createdAt", "")
113
+ if created:
114
+ console.print(f" Created: {created[:19].replace('T', ' ')}")
115
+ completed = m.get("completedAt", "")
116
+ if completed:
117
+ console.print(f" Done: {completed[:19].replace('T', ' ')}")
118
+
119
+ error_msg = m.get("errorMessage")
120
+ if error_msg:
121
+ # Decode escaped newlines/tabs from the stored error string
122
+ rendered = error_msg.replace("\\n", "\n").replace("\\t", "\t")
123
+ console.print()
124
+ if status == "error":
125
+ console.print("[red]Error:[/red]")
126
+ for line in rendered.splitlines():
127
+ console.print(f" [red]{line}[/red]")
128
+ else:
129
+ console.print(f" [dim]{rendered}[/dim]")
130
+
131
+ if not games:
132
+ if status == "queued":
133
+ console.print("\n [dim]Match is queued — waiting for a runner...[/dim]\n")
134
+ elif status == "running":
135
+ console.print("\n [dim]Match is running...[/dim]\n")
136
+ else:
137
+ console.print("\n [dim]No game data.[/dim]\n")
138
+ return
139
+
140
+ console.print()
141
+ table = Table(show_header=True, header_style="bold")
142
+ table.add_column("#", justify="right")
143
+ table.add_column("Map")
144
+ table.add_column("Winner", justify="center")
145
+ table.add_column("Condition")
146
+ table.add_column("Turns", justify="right")
147
+
148
+ for g in games:
149
+ game_num = str(g.get("gameNumber", "?"))
150
+ map_name = g.get("mapName", "?")
151
+ g_winner = g.get("winnerId")
152
+
153
+ if g_winner and g_winner == team_a_id:
154
+ winner = f"[green]A ({team_a})[/green]"
155
+ elif g_winner and g_winner == team_b_id:
156
+ winner = f"[green]B ({team_b})[/green]"
157
+ else:
158
+ winner = "[dim]--[/dim]"
159
+
160
+ raw_condition = g.get("winCondition", "?")
161
+ condition = _CONDITION_LABELS.get(raw_condition, raw_condition)
162
+ turns = str(g.get("turnsPlayed", "?"))
163
+ table.add_row(game_num, map_name, winner, condition, turns)
164
+
165
+ console.print(table)
166
+ console.print()
167
+
168
+
169
+ @click.command("match")
170
+ @click.argument("match_id")
171
+ def match_detail(match_id: str):
172
+ """View detailed info for a match, including individual games."""
173
+ _show_match_detail(match_id)
@@ -0,0 +1,163 @@
1
+ """fcode match — view, list, and manage matches."""
2
+
3
+ import click
4
+ from rich.console import Console
5
+
6
+ from fcode.compat import SmartGroup
7
+
8
+ console = Console()
9
+
10
+
11
+ @click.group(cls=SmartGroup, default_cmd="info")
12
+ def match():
13
+ """View, list, and manage matches."""
14
+ pass
15
+
16
+
17
+ # --- info (default subcommand) ---
18
+ from fcode.commands.match_detail import _show_match_detail
19
+
20
+
21
+ @match.command()
22
+ @click.argument("match_id")
23
+ def info(match_id: str):
24
+ """View detailed info for a match, including individual games."""
25
+ _show_match_detail(match_id)
26
+
27
+
28
+ # --- list ---
29
+ from fcode.commands.matches import _show_matches
30
+
31
+
32
+ @match.command("list")
33
+ @click.option(
34
+ "--type",
35
+ "match_type",
36
+ default=None,
37
+ type=click.Choice(["ladder", "unrated"]),
38
+ help="Filter by match type (shows all if omitted)",
39
+ )
40
+ @click.option("--team", default=None, help="Filter by team name or team ID")
41
+ @click.option("--mine", is_flag=True, help="Show only your team's matches")
42
+ @click.option("--limit", default=20, type=int, help="Number of matches to show (max 100)")
43
+ @click.option("--cursor", default=None, help="Pagination cursor (from previous page)")
44
+ def list_matches(match_type, team, mine, limit, cursor):
45
+ """List recent matches. Use --type to filter by ladder or unrated."""
46
+ _show_matches(match_type, team, limit, cursor, mine=mine)
47
+
48
+
49
+ # --- unrated ---
50
+ from fcode.commands.test import _show_unrated
51
+
52
+
53
+ @match.command()
54
+ @click.argument("opponent_id")
55
+ @click.option("--match", "source_match", default=None, help="Use opponent's submission from this match ID")
56
+ @click.option("--map", "maps", multiple=True, help="Map name (repeatable, up to 5). Omit for random.")
57
+ def unrated(opponent_id: str, source_match: str | None, maps: tuple[str, ...]):
58
+ """Request an unrated match against OPPONENT_ID.
59
+
60
+ Use `fcode team search` to find team IDs.
61
+
62
+ With --map, specify which maps to play (up to 5). Omit for random selection.
63
+ Example: fcode match unrated <id> --map arena --map fortress
64
+ """
65
+ _show_unrated(opponent_id, source_match, maps if maps else None)
66
+
67
+
68
+ # --- test ---
69
+ from fcode.commands.test_run import _show_test_run
70
+
71
+
72
+ @match.command("test")
73
+ @click.argument("bot_a")
74
+ @click.argument("bot_b")
75
+ @click.argument("maps", nargs=-1)
76
+ def test(bot_a: str, bot_b: str, maps: tuple[str, ...]):
77
+ """Run a remote test match between two local bots with TLE enforcement.
78
+
79
+ \b
80
+ BOT_A and BOT_B can be directories (containing main.py), single .py files,
81
+ or .zip archives.
82
+ MAPS are optional .map26 files — one per game. If omitted, 5 random maps.
83
+ """
84
+ _show_test_run(bot_a, bot_b, maps)
85
+
86
+
87
+ # --- replay ---
88
+
89
+ @match.command()
90
+ @click.argument("match_id")
91
+ @click.option("--game", "-g", default=None, type=int, help="Game number (1-5). Downloads all if omitted.")
92
+ @click.option("--output", "-o", default=None, help="Output file path (default: <matchId>_game_<N>.replay26)")
93
+ def replay(match_id: str, game: int | None, output: str | None):
94
+ """Download replay file(s) for a match.
95
+
96
+ Downloads .replay26 files locally for offline viewing with `fcode watch`.
97
+ """
98
+ import urllib.request
99
+ from fcode.api import api_get
100
+
101
+ # Get match games to know how many there are
102
+ data = api_get(f"/api/matches/{match_id}")
103
+ games = data.get("games", [])
104
+
105
+ if not games:
106
+ console.print("[red]No games found for this match (match may still be running).[/red]")
107
+ raise SystemExit(1)
108
+
109
+ if game is not None:
110
+ targets = [g for g in games if g.get("gameNumber") == game]
111
+ if not targets:
112
+ console.print(f"[red]Game {game} not found. Match has games 1-{len(games)}.[/red]")
113
+ raise SystemExit(1)
114
+ else:
115
+ targets = games
116
+
117
+ for g in targets:
118
+ gn = g.get("gameNumber", 0)
119
+ replay_data = api_get("/api/matches/replay", {"matchId": match_id, "game": str(gn)})
120
+ url = replay_data.get("url")
121
+ if not url:
122
+ console.print(f" [yellow]Game {gn}: no replay available[/yellow]")
123
+ continue
124
+
125
+ if output and len(targets) == 1:
126
+ out_path = output
127
+ else:
128
+ out_path = f"{match_id}_game_{gn}.replay26"
129
+
130
+ urllib.request.urlretrieve(url, out_path)
131
+ console.print(f" [green]Game {gn}:[/green] {out_path}")
132
+
133
+ console.print(f"\n[dim]View with: fcode watch <file>[/dim]")
134
+
135
+
136
+ # --- watch ---
137
+
138
+ @match.command("watch")
139
+ @click.argument("match_id")
140
+ @click.option("--game", "-g", default=None, type=int, help="Game number within match")
141
+ def watch(match_id: str, game: int | None):
142
+ """Open a match replay in the visualizer (browser).
143
+
144
+ Shortcut for `fcode watch --match <id>`.
145
+ """
146
+ from fcode.auth import get_api_url
147
+ import webbrowser
148
+
149
+ url = f"{get_api_url()}/visualiser?matchId={match_id}"
150
+ if game is not None:
151
+ url += f"&game={game}"
152
+ webbrowser.open(url)
153
+
154
+
155
+ # --- tests ---
156
+ from fcode.commands.test_matches import _show_test_matches
157
+
158
+
159
+ @match.command("tests")
160
+ @click.option("--limit", default=20, type=int, help="Number of test runs to show")
161
+ def tests(limit: int):
162
+ """List your remote test run matches."""
163
+ _show_test_matches(limit)
@@ -0,0 +1,127 @@
1
+ """fcode matches — list and filter matches."""
2
+
3
+ import click
4
+ from rich.console import Console
5
+ from rich.table import Table
6
+
7
+ from fcode.api import api_get
8
+ from fcode.auth import load_credentials
9
+
10
+ console = Console()
11
+
12
+
13
+ def _show_matches(match_type: str | None, team: str | None, limit: int, cursor: str | None, mine: bool = False):
14
+ """Core implementation for listing matches."""
15
+ params: dict[str, str | list[str]] = {"limit": str(min(limit, 100))}
16
+ if match_type:
17
+ params["type"] = match_type
18
+ if mine and not team:
19
+ creds = load_credentials()
20
+ if creds and creds.get("team"):
21
+ params["teamIds"] = creds["team"]["id"]
22
+ else:
23
+ console.print("[red]Not on a team. Use --team instead.[/red]")
24
+ raise SystemExit(1)
25
+ elif team:
26
+ # Try to resolve team name to ID via search
27
+ search_data = api_get("/api/teams/search", {"q": team})
28
+ search_results = search_data.get("teams", [])
29
+ # Check for exact match first, then fall back to first result
30
+ exact = [t for t in search_results if t["teamName"].lower() == team.lower()]
31
+ if exact:
32
+ params["teamIds"] = exact[0]["teamId"]
33
+ elif search_results:
34
+ params["teamIds"] = search_results[0]["teamId"]
35
+ else:
36
+ # Maybe it's already a team ID
37
+ params["teamIds"] = team
38
+ if cursor:
39
+ params["cursor"] = cursor
40
+
41
+ data = api_get("/api/matches", params)
42
+ match_list = data.get("matches", [])
43
+ next_cursor = data.get("nextCursor")
44
+
45
+ if not match_list:
46
+ label = match_type or "all"
47
+ console.print(f"[dim]No {label} matches found.[/dim]")
48
+ return
49
+
50
+ creds = load_credentials()
51
+ my_team_id = None
52
+ if creds and creds.get("team"):
53
+ my_team_id = creds["team"].get("id")
54
+
55
+ show_type = match_type is None
56
+
57
+ table = Table(show_header=True, header_style="bold")
58
+ table.add_column("Match ID", style="dim")
59
+ if show_type:
60
+ table.add_column("Type", style="dim")
61
+ table.add_column("Team A")
62
+ table.add_column("Score", justify="center")
63
+ table.add_column("Team B")
64
+ table.add_column("Status")
65
+ table.add_column("Date", style="dim")
66
+
67
+ for m in match_list:
68
+ team_a_name = m.get("teamAName", "?")
69
+ team_b_name = m.get("teamBName", "?")
70
+ team_a_id = m.get("teamAId", "")
71
+ team_b_id = m.get("teamBId", "")
72
+
73
+ if m.get("sourceMatchAId"):
74
+ team_a_name += " [dim](OLD)[/dim]"
75
+ if m.get("sourceMatchBId"):
76
+ team_b_name += " [dim](OLD)[/dim]"
77
+
78
+ if my_team_id and team_a_id == my_team_id:
79
+ team_a_name = f"[bold]{team_a_name}[/bold]"
80
+ if my_team_id and team_b_id == my_team_id:
81
+ team_b_name = f"[bold]{team_b_name}[/bold]"
82
+
83
+ winner_id = m.get("winnerId")
84
+ if winner_id == team_a_id:
85
+ team_a_name = f"[green]{team_a_name}[/green]"
86
+ team_b_name = f"[red]{team_b_name}[/red]"
87
+ elif winner_id == team_b_id:
88
+ team_a_name = f"[red]{team_a_name}[/red]"
89
+ team_b_name = f"[green]{team_b_name}[/green]"
90
+
91
+ score = f"{m.get('scoreA', 0)}-{m.get('scoreB', 0)}"
92
+ status = m.get("status", "?")
93
+ match_id = m.get("id", "?")
94
+
95
+ date = ""
96
+ raw_date = m.get("completedAt") or m.get("createdAt")
97
+ if raw_date:
98
+ date = raw_date[:16].replace("T", " ")
99
+
100
+ row: list[str] = [match_id]
101
+ if show_type:
102
+ triggered = m.get("triggeredBy", "?")
103
+ row.append("UR" if triggered == "unrated" else triggered)
104
+ row.extend([team_a_name, score, team_b_name, status, date])
105
+ table.add_row(*row)
106
+
107
+ console.print(table)
108
+
109
+ if next_cursor:
110
+ console.print(f"\n[dim]More results available. Use --cursor '{next_cursor}'[/dim]")
111
+
112
+
113
+ @click.command()
114
+ @click.option(
115
+ "--type",
116
+ "match_type",
117
+ default=None,
118
+ type=click.Choice(["ladder", "unrated"]),
119
+ help="Filter by match type (shows all if omitted)",
120
+ )
121
+ @click.option("--team", default=None, help="Filter by team name or team ID")
122
+ @click.option("--mine", is_flag=True, help="Show only your team's matches")
123
+ @click.option("--limit", default=20, type=int, help="Number of matches to show (max 100)")
124
+ @click.option("--cursor", default=None, help="Pagination cursor (from previous page)")
125
+ def matches(match_type: str | None, team: str | None, mine: bool, limit: int, cursor: str | None):
126
+ """List recent matches. Use --type to filter by ladder or unrated."""
127
+ _show_matches(match_type, team, limit, cursor, mine=mine)
fcode/commands/run.py ADDED
@@ -0,0 +1,186 @@
1
+ import os
2
+ from pathlib import Path
3
+
4
+ import click
5
+ from rich.console import Console
6
+
7
+ from fcode.config import find_config
8
+
9
+ console = Console()
10
+
11
+
12
+ def _print_summary(console: Console, result: dict, name_a: str, name_b: str):
13
+ winner = result["winner"]
14
+ turns = result["turns"]
15
+ condition = result["win_condition"]
16
+ resign_message = result.get("resign_message")
17
+
18
+ condition_labels = {
19
+ "core_destroyed": "Core destroyed",
20
+ "resigned": "Resigned",
21
+ "resources": "Resources (tiebreak)",
22
+ "titanium_collected": "Titanium collected (tiebreak)",
23
+ "harvesters": "Harvesters (tiebreak)",
24
+ "titanium_stored": "Titanium stored (tiebreak)",
25
+ "coinflip": "Coinflip (tiebreak)",
26
+ "timeout": "Draw",
27
+ }
28
+ reason = condition_labels.get(condition, condition)
29
+
30
+ if winner == "A":
31
+ winner_name = name_a
32
+ winner_style = "[bold green]"
33
+ elif winner == "B":
34
+ winner_name = name_b
35
+ winner_style = "[bold green]"
36
+ else:
37
+ winner_name = "Draw"
38
+ winner_style = "[bold yellow]"
39
+
40
+ console.print()
41
+ console.print(f" {winner_style}Winner: {winner_name}[/] [dim]({reason}, turn {turns})[/dim]")
42
+ if resign_message:
43
+ console.print(f" [dim italic]Resign message: {resign_message}[/dim italic]")
44
+ console.print()
45
+
46
+ # Resource table
47
+ from rich.table import Table
48
+ t = Table(show_header=True, box=None, padding=(0, 2))
49
+ t.add_column("", style="dim")
50
+ a_style = "bold" if winner == "A" else ""
51
+ b_style = "bold" if winner == "B" else ""
52
+ t.add_column(name_a, justify="right", style=a_style)
53
+ t.add_column(name_b, justify="right", style=b_style)
54
+ t.add_row(
55
+ "Titanium",
56
+ f"{result['a_titanium']} ({result['a_titanium_collected']} mined)",
57
+ f"{result['b_titanium']} ({result['b_titanium_collected']} mined)",
58
+ )
59
+ t.add_row("Units", str(result["a_units"]), str(result["b_units"]))
60
+ t.add_row("Buildings", str(result["a_buildings"]), str(result["b_buildings"]))
61
+ console.print(t)
62
+ console.print()
63
+
64
+
65
+ def resolve_map_path(path_str: str, maps_dir: Path) -> Path:
66
+ """Resolve a map path. Tries: raw path, maps_dir/path, path+.map26, maps_dir/path+.map26."""
67
+ p = Path(path_str)
68
+ if p.exists():
69
+ return p
70
+
71
+ if not p.is_absolute():
72
+ # Try maps_dir/path
73
+ candidate = maps_dir / path_str
74
+ if candidate.exists():
75
+ return candidate
76
+ # Try adding .map26 extension
77
+ if not path_str.endswith(".map26"):
78
+ with_ext = Path(path_str + ".map26")
79
+ if with_ext.exists():
80
+ return with_ext
81
+ candidate_ext = maps_dir / (path_str + ".map26")
82
+ if candidate_ext.exists():
83
+ return candidate_ext
84
+
85
+ return p # Return as-is; caller will get a file-not-found naturally
86
+
87
+
88
+ def resolve_bot_path(path_str: str, bots_dir: Path) -> str:
89
+ """Resolve a bot path. Checks raw path first, then bots_dir/path."""
90
+ p = Path(path_str)
91
+
92
+ # If it's already a valid path, use it directly
93
+ if not p.exists() and not p.is_absolute():
94
+ # Try resolving relative to bots_dir
95
+ candidate = bots_dir / path_str
96
+ if candidate.exists():
97
+ p = candidate
98
+
99
+ if p.is_dir():
100
+ main_py = p / "main.py"
101
+ if main_py.is_file():
102
+ return str(main_py.resolve())
103
+ raise click.BadParameter(
104
+ f"Directory '{path_str}' does not contain main.py"
105
+ )
106
+ if p.is_file():
107
+ return str(p.resolve())
108
+ raise click.BadParameter(f"Bot not found: '{path_str}'")
109
+
110
+
111
+ @click.command()
112
+ @click.argument("bot_a")
113
+ @click.argument("bot_b")
114
+ @click.argument("map_path", required=False, default=None)
115
+ @click.option("--replay", default=None, help="Output replay path")
116
+ @click.option("--seed", default=None, type=int)
117
+ @click.option("--watch", "auto_watch", is_flag=True, help="Open visualizer after match")
118
+ @click.option("--tle", default=0, type=int, help="Turn time limit in ms (0 to disable, server uses 10)")
119
+ @click.option("--map-random", is_flag=True, help="Pick a random map from maps directory")
120
+ def run(bot_a: str, bot_b: str, map_path: str | None, replay: str | None, seed: int | None, auto_watch: bool, tle: int, map_random: bool):
121
+ """Run a local match between two bots.
122
+
123
+ MAP_PATH is optional — if omitted, uses the first .map26 file in the maps directory.
124
+ """
125
+ from fcode.fcode_engine import run_game
126
+
127
+ config, project_root = find_config()
128
+
129
+ # Apply config defaults where CLI didn't override
130
+ replay = replay or config.replay
131
+ seed = seed if seed is not None else config.seed
132
+ bots_dir = (project_root / config.bots_dir).resolve()
133
+ maps_dir = (project_root / config.maps_dir).resolve()
134
+
135
+ # Game types are re-exported in fcode/__init__.py via _types.py,
136
+ # so `from fcode import *` already works. engine_root is passed to the
137
+ # Rust engine for sys.path setup (redundant here, needed by server binary).
138
+ engine_root = str(Path(__file__).resolve().parent.parent)
139
+
140
+ player_a = resolve_bot_path(bot_a, bots_dir)
141
+ player_b = resolve_bot_path(bot_b, bots_dir)
142
+
143
+ # Resolve map — pick first (or random) if not specified
144
+ if map_path is None:
145
+ maps = sorted(maps_dir.glob("*.map26"))
146
+ if not maps:
147
+ console.print("[red]No .map26 files found in maps/ directory. Provide a map path.[/red]")
148
+ raise SystemExit(1)
149
+ if map_random:
150
+ import random
151
+ mp = random.choice(maps)
152
+ else:
153
+ mp = maps[0]
154
+ else:
155
+ mp = resolve_map_path(map_path, maps_dir)
156
+ resolved_map = str(mp.resolve())
157
+
158
+ name_a = Path(player_a).parent.name if Path(player_a).name == "main.py" else Path(player_a).stem
159
+ name_b = Path(player_b).parent.name if Path(player_b).name == "main.py" else Path(player_b).stem
160
+
161
+ console.print(f"[bold]Running match:[/bold] {name_a} vs {name_b}")
162
+ tle_label = "off" if tle == 0 else f"{tle}ms"
163
+ console.print(f" Map: {mp.name} Seed: {seed} Replay: {replay} TLE: {tle_label}")
164
+
165
+ try:
166
+ result = run_game(player_a, player_b, engine_root, resolved_map, replay, seed, tle)
167
+ except Exception as e:
168
+ console.print(f"[red bold]Error:[/red bold] {e}")
169
+ raise SystemExit(1)
170
+
171
+ if os.path.exists(replay):
172
+ console.print(f"[green]Replay written to {replay}[/green]")
173
+
174
+ _print_summary(console, result, name_a, name_b)
175
+
176
+ if auto_watch:
177
+ from fcode.commands import watch as watch_mod
178
+ ctx = click.get_current_context()
179
+ ctx.invoke(watch_mod.watch, replay_file=replay)
180
+
181
+ # Skip normal Py_Finalize to avoid CPython 3.12 bug where
182
+ # Py_EndInterpreter doesn't fully unregister sub-interpreters,
183
+ # causing a fatal "remaining subinterpreters" abort on exit.
184
+ os._exit(0)
185
+
186
+ # do not put code after this