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
fcode/api.py ADDED
@@ -0,0 +1,146 @@
1
+ """HTTP client helpers for fcode CLI — handles auth, errors, multipart."""
2
+
3
+ import json
4
+ import urllib.request
5
+ import urllib.error
6
+ from pathlib import Path
7
+
8
+ from rich.console import Console
9
+
10
+ from fcode.auth import get_api_url, get_token
11
+
12
+ console = Console()
13
+
14
+
15
+ class ApiError(Exception):
16
+ def __init__(self, message: str, status: int = 0):
17
+ self.message = message
18
+ self.status = status
19
+ super().__init__(message)
20
+
21
+
22
+ def _require_token() -> str:
23
+ token = get_token()
24
+ if not token:
25
+ console.print("[red]Not logged in. Run: fcode login[/red]")
26
+ raise SystemExit(1)
27
+ return token
28
+
29
+
30
+ def _handle_error(e: urllib.error.HTTPError) -> None:
31
+ try:
32
+ body = json.loads(e.read())
33
+ msg = body.get("error", str(e))
34
+ except Exception:
35
+ msg = str(e)
36
+
37
+ if e.code == 401:
38
+ console.print("[red]Session expired. Run: fcode login[/red]")
39
+ elif e.code == 429:
40
+ console.print(f"[yellow]Rate limited: {msg}[/yellow]")
41
+ elif e.code == 404:
42
+ console.print(f"[red]Not found: {msg}[/red]")
43
+ else:
44
+ console.print(f"[red]Error: {msg}[/red]")
45
+ raise SystemExit(1)
46
+
47
+
48
+ def api_get(path: str, params: dict[str, str] | None = None) -> dict:
49
+ """Authenticated GET request. Returns parsed JSON."""
50
+ from urllib.parse import urlencode
51
+
52
+ token = _require_token()
53
+ api_url = get_api_url()
54
+ url = f"{api_url}{path}"
55
+ if params:
56
+ url += f"?{urlencode(params)}"
57
+
58
+ req = urllib.request.Request(
59
+ url,
60
+ headers={"Authorization": f"Bearer {token}"},
61
+ method="GET",
62
+ )
63
+ try:
64
+ with urllib.request.urlopen(req, timeout=15) as resp:
65
+ return json.loads(resp.read())
66
+ except urllib.error.HTTPError as e:
67
+ _handle_error(e)
68
+ raise # unreachable but satisfies type checker
69
+ except Exception as e:
70
+ console.print(f"[red]Request failed: {e}[/red]")
71
+ raise SystemExit(1)
72
+
73
+
74
+ def api_post(path: str, body: dict) -> dict:
75
+ """Authenticated POST request with JSON body. Returns parsed JSON."""
76
+ token = _require_token()
77
+ api_url = get_api_url()
78
+
79
+ payload = json.dumps(body).encode()
80
+ req = urllib.request.Request(
81
+ f"{api_url}{path}",
82
+ data=payload,
83
+ headers={
84
+ "Content-Type": "application/json",
85
+ "Authorization": f"Bearer {token}",
86
+ },
87
+ method="POST",
88
+ )
89
+ try:
90
+ with urllib.request.urlopen(req, timeout=15) as resp:
91
+ return json.loads(resp.read())
92
+ except urllib.error.HTTPError as e:
93
+ _handle_error(e)
94
+ raise
95
+ except Exception as e:
96
+ console.print(f"[red]Request failed: {e}[/red]")
97
+ raise SystemExit(1)
98
+
99
+
100
+ def api_post_multipart(
101
+ path: str,
102
+ files: dict[str, tuple[str, bytes, str]],
103
+ text_fields: dict[str, str] | None = None,
104
+ timeout: int = 60,
105
+ ) -> dict:
106
+ """Authenticated POST with multipart/form-data. Returns parsed JSON."""
107
+ token = _require_token()
108
+ api_url = get_api_url()
109
+
110
+ boundary = "----FcodeBoundary"
111
+ parts: list[bytes] = []
112
+ if text_fields:
113
+ for name, value in text_fields.items():
114
+ parts.append(f"--{boundary}\r\n".encode())
115
+ parts.append(f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode())
116
+ parts.append(value.encode())
117
+ parts.append(b"\r\n")
118
+ for name, (filename, data, content_type) in files.items():
119
+ parts.append(f"--{boundary}\r\n".encode())
120
+ parts.append(
121
+ f'Content-Disposition: form-data; name="{name}"; filename="{filename}"\r\n'.encode()
122
+ )
123
+ parts.append(f"Content-Type: {content_type}\r\n\r\n".encode())
124
+ parts.append(data)
125
+ parts.append(b"\r\n")
126
+ parts.append(f"--{boundary}--\r\n".encode())
127
+ body = b"".join(parts)
128
+
129
+ req = urllib.request.Request(
130
+ f"{api_url}{path}",
131
+ data=body,
132
+ headers={
133
+ "Content-Type": f"multipart/form-data; boundary={boundary}",
134
+ "Authorization": f"Bearer {token}",
135
+ },
136
+ method="POST",
137
+ )
138
+ try:
139
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
140
+ return json.loads(resp.read())
141
+ except urllib.error.HTTPError as e:
142
+ _handle_error(e)
143
+ raise
144
+ except Exception as e:
145
+ console.print(f"[red]Upload failed: {e}[/red]")
146
+ raise SystemExit(1)
fcode/auth.py ADDED
@@ -0,0 +1,49 @@
1
+ """Credential storage for fcode CLI authentication."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ CREDENTIALS_DIR = Path.home() / ".fcode"
7
+ CREDENTIALS_FILE = CREDENTIALS_DIR / "credentials.json"
8
+
9
+ # Patched to the platform URL at release build time; overridable via FCODE_API_URL env var
10
+ DEFAULT_API_URL = "https://game.code.florent.vc"
11
+
12
+
13
+ def get_api_url() -> str:
14
+ import os
15
+ return os.environ.get("FCODE_API_URL", DEFAULT_API_URL)
16
+
17
+
18
+ def save_credentials(token: str, expires_at: str, user: dict, team: dict | None) -> None:
19
+ CREDENTIALS_DIR.mkdir(parents=True, exist_ok=True)
20
+ data = {
21
+ "token": token,
22
+ "expires_at": expires_at,
23
+ "user": user,
24
+ "team": team,
25
+ }
26
+ CREDENTIALS_FILE.write_text(json.dumps(data, indent=2))
27
+ # Restrict permissions to owner only
28
+ CREDENTIALS_FILE.chmod(0o600)
29
+
30
+
31
+ def load_credentials() -> dict | None:
32
+ if not CREDENTIALS_FILE.exists():
33
+ return None
34
+ try:
35
+ return json.loads(CREDENTIALS_FILE.read_text())
36
+ except (json.JSONDecodeError, OSError):
37
+ return None
38
+
39
+
40
+ def clear_credentials() -> None:
41
+ if CREDENTIALS_FILE.exists():
42
+ CREDENTIALS_FILE.unlink()
43
+
44
+
45
+ def get_token() -> str | None:
46
+ creds = load_credentials()
47
+ if not creds:
48
+ return None
49
+ return creds.get("token")
fcode/cli.py ADDED
@@ -0,0 +1,127 @@
1
+ import click
2
+ from rich.console import Console
3
+
4
+ from fcode import __version__
5
+ from fcode.compat import deprecation_warning
6
+
7
+ console = Console()
8
+
9
+ @click.group()
10
+ @click.version_option(version=__version__)
11
+ def main():
12
+ """Florent Code League CLI"""
13
+ from fcode.version_check import check_for_update
14
+ latest = check_for_update()
15
+ if latest:
16
+ console.print(
17
+ f"[yellow]Update available: {__version__} -> {latest}. "
18
+ f"Run: pip install --upgrade fcode[/yellow]"
19
+ )
20
+
21
+ # -- Unchanged top-level commands --
22
+ from fcode.commands import run, watch, login, logout, starter, status, map_editor
23
+ main.add_command(run.run)
24
+ main.add_command(watch.watch)
25
+ main.add_command(login.login)
26
+ main.add_command(logout.logout)
27
+ main.add_command(starter.starter)
28
+ main.add_command(status.status)
29
+ main.add_command(map_editor.map_editor)
30
+
31
+ # -- New entity groups --
32
+ from fcode.commands.submission import submission
33
+ from fcode.commands.match_group import match as match_group
34
+ from fcode.commands.team_group import team
35
+ from fcode.commands.ladder import ladder
36
+ main.add_command(submission)
37
+ main.add_command(match_group)
38
+ main.add_command(team)
39
+ main.add_command(ladder)
40
+
41
+ # -- submit as alias for submission upload --
42
+
43
+ @click.command("submit")
44
+ @click.argument("path", type=click.Path(exists=True))
45
+ @click.option("--name", "-n", default=None, help="Optional name for this submission")
46
+ def submit(path: str, name: str | None):
47
+ """Upload a bot to the platform (alias for `submission upload`)."""
48
+ from fcode.commands.submission import upload
49
+ ctx = click.get_current_context()
50
+ ctx.invoke(upload, path=path, name=name)
51
+
52
+ main.add_command(submit)
53
+
54
+ # -- Deprecated aliases (hidden, show warning) --
55
+
56
+ @click.command("matches", hidden=True)
57
+ @click.option("--type", "match_type", default=None, type=click.Choice(["ladder", "unrated"]))
58
+ @click.option("--team", default=None)
59
+ @click.option("--mine", is_flag=True)
60
+ @click.option("--limit", default=20, type=int)
61
+ @click.option("--cursor", default=None)
62
+ def _dep_matches(match_type, team, mine, limit, cursor):
63
+ """Deprecated. Use: fcode match list"""
64
+ deprecation_warning("matches", "match list")
65
+ from fcode.commands.matches import _show_matches
66
+ _show_matches(match_type, team, limit, cursor, mine=mine)
67
+
68
+ main.add_command(_dep_matches)
69
+
70
+
71
+ @click.command("unrated", hidden=True)
72
+ @click.argument("opponent_id")
73
+ @click.option("--match", "source_match", default=None)
74
+ @click.option("--map", "maps", multiple=True, help="Map name (repeatable, up to 5). Omit for random.")
75
+ def _dep_unrated(opponent_id, source_match, maps):
76
+ """Deprecated. Use: fcode match unrated"""
77
+ deprecation_warning("unrated", "match unrated")
78
+ from fcode.commands.test import _show_unrated
79
+ _show_unrated(opponent_id, source_match, maps if maps else None)
80
+
81
+ main.add_command(_dep_unrated)
82
+
83
+
84
+ @click.command("test-run", hidden=True)
85
+ @click.argument("bot_a")
86
+ @click.argument("bot_b")
87
+ @click.option("--map", "maps", multiple=True, help="Map name (repeatable, up to 5). Omit for random.")
88
+ def _dep_test_run(bot_a, bot_b, maps):
89
+ """Deprecated. Use: fcode match test"""
90
+ deprecation_warning("test-run", "match test")
91
+ from fcode.commands.test_run import _show_test_run
92
+ _show_test_run(bot_a, bot_b, maps)
93
+
94
+ main.add_command(_dep_test_run)
95
+
96
+
97
+ @click.command("test-matches", hidden=True)
98
+ @click.option("--limit", default=20, type=int)
99
+ def _dep_test_matches(limit):
100
+ """Deprecated. Use: fcode match tests"""
101
+ deprecation_warning("test-matches", "match tests")
102
+ from fcode.commands.test_matches import _show_test_matches
103
+ _show_test_matches(limit)
104
+
105
+ main.add_command(_dep_test_matches)
106
+
107
+
108
+ # For `teams`, re-register the old group with a deprecation warning
109
+ from fcode.commands.teams import teams as _teams_group
110
+
111
+ class _DeprecatedTeamsGroup(click.Group):
112
+ def invoke(self, ctx):
113
+ deprecation_warning("teams", "team")
114
+ return super().invoke(ctx)
115
+
116
+ _dep_teams = _DeprecatedTeamsGroup("teams", commands=_teams_group.commands, help="Deprecated. Use: fcode team", hidden=True)
117
+ main.add_command(_dep_teams)
118
+
119
+
120
+ @click.command("init", hidden=True)
121
+ def _dep_init():
122
+ """Deprecated. Use: fcode starter"""
123
+ deprecation_warning("init", "starter")
124
+ from fcode.commands.init import init as _init_impl
125
+ click.get_current_context().invoke(_init_impl)
126
+
127
+ main.add_command(_dep_init)
File without changes
fcode/commands/init.py ADDED
@@ -0,0 +1,29 @@
1
+ from pathlib import Path
2
+
3
+ import click
4
+ from rich.console import Console
5
+
6
+ from fcode.config import CONFIG_FILENAME
7
+
8
+ console = Console()
9
+
10
+ DEFAULT_CONFIG = """\
11
+ # fcode.toml — Florent Code League project config
12
+
13
+ bots_dir = "bots"
14
+ maps_dir = "maps"
15
+ replay = "replay.replay26"
16
+ seed = 1
17
+ """
18
+
19
+
20
+ @click.command()
21
+ def init():
22
+ """Initialize a fcode.toml config file in the current directory."""
23
+ config_path = Path.cwd() / CONFIG_FILENAME
24
+ if config_path.exists():
25
+ console.print(f"[yellow]{CONFIG_FILENAME} already exists.[/yellow]")
26
+ return
27
+
28
+ config_path.write_text(DEFAULT_CONFIG)
29
+ console.print(f"[green]Created {CONFIG_FILENAME}[/green]")
@@ -0,0 +1,74 @@
1
+ """fcode ladder — view ladder rankings."""
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
+ @click.command()
14
+ @click.option("--limit", default=20, type=int, help="Number of teams to show")
15
+ @click.option("--around", is_flag=True, help="Show rankings around your team (±5)")
16
+ def ladder(limit: int, around: bool):
17
+ """View ladder rankings."""
18
+ data = api_get("/api/ladder")
19
+ rankings = data if isinstance(data, list) else data.get("rankings", data)
20
+
21
+ if not rankings:
22
+ console.print("[dim]No teams on the ladder yet.[/dim]")
23
+ return
24
+
25
+ # Add rank numbers before filtering (overall ladder position)
26
+ for i, r in enumerate(rankings):
27
+ r["_rank"] = i + 1
28
+
29
+ creds = load_credentials()
30
+ my_team_id = None
31
+ if creds and creds.get("team"):
32
+ my_team_id = creds["team"].get("id")
33
+
34
+ total_after_filter = len(rankings)
35
+
36
+ if around and my_team_id:
37
+ my_idx = next((i for i, r in enumerate(rankings) if r.get("teamId") == my_team_id), None)
38
+ if my_idx is not None:
39
+ start = max(0, my_idx - 5)
40
+ end = min(len(rankings), my_idx + 6)
41
+ rankings = rankings[start:end]
42
+ else:
43
+ console.print("[dim]Your team is not on the ladder.[/dim]")
44
+ return
45
+ else:
46
+ rankings = rankings[:limit]
47
+
48
+ table = Table(show_header=True, header_style="bold")
49
+ table.add_column("#", justify="right", style="dim")
50
+ table.add_column("Team")
51
+ table.add_column("Rating", justify="right")
52
+ table.add_column("Matches", justify="right")
53
+
54
+ for r in rankings:
55
+ rank = str(r["_rank"])
56
+ name = r.get("teamName", "?")
57
+ is_mine = my_team_id and r.get("teamId") == my_team_id
58
+ if is_mine:
59
+ name = f"[bold cyan]{name}[/bold cyan]"
60
+ rank = f"[bold cyan]{rank}[/bold cyan]"
61
+
62
+ rating = f"{r.get('rating', 0):.0f}"
63
+ matches_played = str(r.get("matchesPlayed", 0))
64
+
65
+ table.add_row(
66
+ rank,
67
+ name,
68
+ rating,
69
+ matches_played,
70
+ )
71
+
72
+ console.print(table)
73
+ if not around:
74
+ console.print(f"\n[dim]Showing {len(rankings)} of {total_after_filter} teams. Use --limit or --around.[/dim]")
@@ -0,0 +1,135 @@
1
+ """fcode login — authenticate via browser-based flow."""
2
+
3
+ import json
4
+ import secrets
5
+ import urllib.request
6
+ import webbrowser
7
+ from http.server import HTTPServer, BaseHTTPRequestHandler
8
+ from urllib.parse import urlparse, parse_qs
9
+
10
+ import click
11
+ from rich.console import Console
12
+
13
+ from fcode.auth import get_api_url, save_credentials, load_credentials
14
+
15
+ console = Console()
16
+
17
+
18
+ class _CallbackHandler(BaseHTTPRequestHandler):
19
+ """Handles the localhost callback from the browser."""
20
+
21
+ code: str | None = None
22
+ state: str | None = None
23
+
24
+ def do_GET(self):
25
+ parsed = urlparse(self.path)
26
+ params = parse_qs(parsed.query)
27
+
28
+ self.server._code = params.get("code", [None])[0]
29
+ self.server._state = params.get("state", [None])[0]
30
+
31
+ self.send_response(200)
32
+ self.send_header("Content-Type", "text/html")
33
+ self.end_headers()
34
+ self.wfile.write(
35
+ b"<html><body style='font-family:system-ui;display:flex;justify-content:center;"
36
+ b"align-items:center;height:100vh;margin:0;background:#0a0a0a;color:#fafafa'>"
37
+ b"<div style='text-align:center'>"
38
+ b"<h2>Authorized</h2>"
39
+ b"<p>You can close this tab and return to your terminal.</p>"
40
+ b"</div></body></html>"
41
+ )
42
+
43
+ def log_message(self, format, *args):
44
+ pass # Suppress request logging
45
+
46
+
47
+ @click.command()
48
+ def login():
49
+ """Authenticate with the Florent Code League platform."""
50
+ existing = load_credentials()
51
+ if existing:
52
+ user = existing.get("user", {})
53
+ console.print(
54
+ f"Already logged in as [bold]{user.get('name', '?')}[/bold] ({user.get('email', '?')})"
55
+ )
56
+ if not click.confirm("Log in again?", default=False):
57
+ return
58
+
59
+ api_url = get_api_url()
60
+ state = secrets.token_urlsafe(32)
61
+
62
+ # Start a local server to receive the callback
63
+ server = HTTPServer(("127.0.0.1", 0), _CallbackHandler)
64
+ port = server.server_address[1]
65
+
66
+ auth_url = f"{api_url}/cli/auth?port={port}&state={state}"
67
+
68
+ console.print(f"Opening browser to authenticate...")
69
+ console.print(f"If the browser doesn't open, visit: [link={auth_url}]{auth_url}[/link]")
70
+
71
+ webbrowser.open(auth_url)
72
+
73
+ # Wait for the callback (with timeout)
74
+ server.timeout = 120
75
+ server._code = None
76
+ server._state = None
77
+
78
+ console.print("Waiting for authorization...", style="dim")
79
+ while server._code is None:
80
+ server.handle_request()
81
+ if server._code is None and server._state is None:
82
+ # Timeout or unexpected request
83
+ break
84
+
85
+ server.server_close()
86
+
87
+ if not server._code:
88
+ console.print("[red]Authorization timed out or was cancelled.[/red]")
89
+ raise SystemExit(1)
90
+
91
+ if server._state != state:
92
+ console.print("[red]State mismatch — possible CSRF attack. Aborting.[/red]")
93
+ raise SystemExit(1)
94
+
95
+ # Exchange the code for a session token
96
+ console.print("Exchanging authorization code...", style="dim")
97
+ try:
98
+ payload = json.dumps({"code": server._code}).encode()
99
+ req = urllib.request.Request(
100
+ f"{api_url}/api/cli/exchange",
101
+ data=payload,
102
+ headers={"Content-Type": "application/json"},
103
+ method="POST",
104
+ )
105
+ with urllib.request.urlopen(req, timeout=10) as resp:
106
+ data = json.loads(resp.read())
107
+ except urllib.error.HTTPError as e:
108
+ try:
109
+ body = json.loads(e.read())
110
+ msg = body.get("error", str(e))
111
+ except Exception:
112
+ msg = str(e)
113
+ console.print(f"[red]Authentication failed: {msg}[/red]")
114
+ raise SystemExit(1)
115
+ except Exception as e:
116
+ console.print(f"[red]Authentication failed: {e}[/red]")
117
+ raise SystemExit(1)
118
+
119
+ token = data.get("token")
120
+ if not token:
121
+ console.print("[red]Invalid response from server.[/red]")
122
+ raise SystemExit(1)
123
+
124
+ save_credentials(
125
+ token=token,
126
+ expires_at=data.get("expires_at", ""),
127
+ user=data.get("user", {}),
128
+ team=data.get("team"),
129
+ )
130
+
131
+ user = data.get("user", {})
132
+ team = data.get("team")
133
+ console.print(f"\n[green]Logged in as [bold]{user.get('name', '?')}[/bold][/green] ({user.get('email', '?')})")
134
+ if team:
135
+ console.print(f" Team: [bold]{team.get('name', '?')}[/bold]")
@@ -0,0 +1,21 @@
1
+ """fcode logout — clear stored credentials."""
2
+
3
+ import click
4
+ from rich.console import Console
5
+
6
+ from fcode.auth import clear_credentials, load_credentials
7
+
8
+ console = Console()
9
+
10
+
11
+ @click.command()
12
+ def logout():
13
+ """Clear stored authentication credentials."""
14
+ existing = load_credentials()
15
+ if not existing:
16
+ console.print("Not currently logged in.")
17
+ return
18
+
19
+ clear_credentials()
20
+ user = existing.get("user", {})
21
+ console.print(f"Logged out (was [bold]{user.get('name', '?')}[/bold]).")
@@ -0,0 +1,91 @@
1
+ """fcode map-editor — open map editor locally or on platform."""
2
+
3
+ import click
4
+ from rich.console import Console
5
+
6
+ console = Console()
7
+
8
+
9
+ @click.command("map-editor")
10
+ @click.option("--platform", "use_platform", is_flag=True, help="Open map editor on platform instead of locally")
11
+ def map_editor(use_platform: bool):
12
+ """Open the map editor.
13
+
14
+ \b
15
+ Examples:
16
+ fcode map-editor # Open map editor locally
17
+ fcode map-editor --platform # Open on platform
18
+ """
19
+ if use_platform:
20
+ _open_platform()
21
+ else:
22
+ _serve_local()
23
+
24
+
25
+ def _open_platform() -> None:
26
+ """Open the platform map editor page in the user's browser."""
27
+ import webbrowser
28
+ from fcode.auth import get_api_url
29
+
30
+ base_url = get_api_url()
31
+ url = f"{base_url}/map-editor"
32
+
33
+ console.print(f"Opening map editor: [link={url}]{url}[/link]")
34
+ webbrowser.open(url)
35
+
36
+
37
+ def _serve_local() -> None:
38
+ """Serve the map editor locally."""
39
+ import http.server
40
+ import threading
41
+ import webbrowser
42
+ from pathlib import Path
43
+
44
+ mono_dist = Path(__file__).parent.parent.parent.parent.parent / "visualiser" / "dist"
45
+ bundled_dist = Path(__file__).parent.parent / "data" / "visualiser"
46
+
47
+ if (mono_dist / "map-editor.html").is_file():
48
+ dist_path = str(mono_dist.resolve())
49
+ elif (bundled_dist / "map-editor.html").is_file():
50
+ dist_path = str(bundled_dist.resolve())
51
+ else:
52
+ console.print(
53
+ "[red]Map editor not found. "
54
+ "Try reinstalling fcode or use --platform to open on the platform.[/red]"
55
+ )
56
+ raise SystemExit(1)
57
+
58
+ # Ensure correct MIME types on Windows (registry may map .js to text/plain)
59
+ import mimetypes
60
+
61
+ mimetypes.add_type("application/javascript", ".js")
62
+ mimetypes.add_type("application/javascript", ".mjs")
63
+ mimetypes.add_type("application/wasm", ".wasm")
64
+ mimetypes.add_type("text/css", ".css")
65
+
66
+ class EditorHandler(http.server.SimpleHTTPRequestHandler):
67
+ def __init__(self, *args, **kwargs):
68
+ super().__init__(*args, directory=dist_path, **kwargs)
69
+
70
+ def do_GET(self):
71
+ if self.path == "/" or self.path.startswith("/?"):
72
+ self.path = "/map-editor.html"
73
+ super().do_GET()
74
+
75
+ def log_message(self, format, *args):
76
+ pass
77
+
78
+ server = http.server.HTTPServer(("127.0.0.1", 0), EditorHandler)
79
+ port = server.server_address[1]
80
+
81
+ url = f"http://127.0.0.1:{port}/"
82
+ console.print(f"Serving map editor at [link={url}]{url}[/link]")
83
+ console.print("[dim]Press Ctrl+C to stop[/dim]")
84
+
85
+ threading.Timer(0.5, lambda: webbrowser.open(url)).start()
86
+
87
+ try:
88
+ server.serve_forever()
89
+ except KeyboardInterrupt:
90
+ console.print("\n[dim]Stopped.[/dim]")
91
+ server.shutdown()