fcode 2.2.0__cp312-cp312-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.cp312-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,119 @@
1
+ import importlib.resources
2
+ from pathlib import Path
3
+
4
+ import click
5
+ from rich.console import Console
6
+ from rich.prompt import Confirm
7
+
8
+ from fcode.config import CONFIG_FILENAME
9
+
10
+ console = Console()
11
+
12
+ DEFAULT_CONFIG = """\
13
+ # fcode.toml - Florent Code League project config
14
+
15
+ bots_dir = "bots"
16
+ maps_dir = "maps"
17
+ replay = "replay.replay26"
18
+ seed = 1
19
+ """
20
+
21
+ GITIGNORE = """\
22
+ # Replays (regenerated with fcode run)
23
+ *.replay26
24
+
25
+ # Python
26
+ __pycache__/
27
+ *.pyc
28
+
29
+ # Virtual environments
30
+ .venv/
31
+ venv/
32
+ """
33
+
34
+ STARTER_BOT = importlib.resources.files("fcode.data") / "starter_bot.py"
35
+
36
+
37
+ def _get_maps_source():
38
+ """Resolve maps: repo root `maps/` in development, bundled package data when pip-installed."""
39
+ # In the monorepo: commands/ → fcode/ → python/ → cli/ → repo root
40
+ repo_maps = Path(__file__).resolve().parent.parent.parent.parent.parent / "maps"
41
+ if repo_maps.is_dir() and any(repo_maps.glob("*.map26")):
42
+ return repo_maps
43
+ return importlib.resources.files("fcode.data.maps")
44
+
45
+
46
+ @click.command()
47
+ def starter():
48
+ """Scaffold a new Florent Code League project."""
49
+ project_root = Path.cwd()
50
+ config_path = project_root / CONFIG_FILENAME
51
+
52
+ console.print("[bold]Florent Code League — project setup[/bold]\n")
53
+
54
+ # 1. Config file (always created)
55
+ if config_path.exists():
56
+ console.print(f" [dim]{CONFIG_FILENAME} already exists, skipping.[/dim]")
57
+ else:
58
+ config_path.write_text(DEFAULT_CONFIG, encoding="utf-8")
59
+ console.print(f" [green]+[/green] Created {CONFIG_FILENAME}")
60
+
61
+ # 2. .gitignore
62
+ gitignore_path = project_root / ".gitignore"
63
+ if gitignore_path.exists():
64
+ console.print(" [dim].gitignore already exists, skipping.[/dim]")
65
+ else:
66
+ gitignore_path.write_text(GITIGNORE, encoding="utf-8")
67
+ console.print(" [green]+[/green] Created .gitignore")
68
+
69
+ # 3. Always create bots/ and maps/ directories (config references them)
70
+ bots_dir = project_root / "bots"
71
+ maps_dir = project_root / "maps"
72
+ bots_dir.mkdir(exist_ok=True)
73
+ maps_dir.mkdir(exist_ok=True)
74
+
75
+ # 4. Maps
76
+ include_maps = Confirm.ask("\n Include default maps?", default=True)
77
+ if include_maps:
78
+ count = 0
79
+ available = 0
80
+ maps_source = _get_maps_source()
81
+ for item in maps_source.iterdir():
82
+ if item.name.endswith(".map26"):
83
+ available += 1
84
+ dest = maps_dir / item.name
85
+ if dest.exists():
86
+ continue
87
+ dest.write_bytes(item.read_bytes())
88
+ count += 1
89
+ if count > 0:
90
+ console.print(f" [green]+[/green] Copied {count} maps to maps/")
91
+ elif available > 0:
92
+ console.print(" [dim]All maps already present, skipping.[/dim]")
93
+ else:
94
+ console.print(" [yellow]No default maps found. Download maps from the platform.[/yellow]")
95
+
96
+ # 5. Starter bot
97
+ include_bot = Confirm.ask("\n Create a starter bot?", default=True)
98
+ if include_bot:
99
+ bot_dir = bots_dir / "starter"
100
+ bot_dir.mkdir(parents=True, exist_ok=True)
101
+ main_py = bot_dir / "main.py"
102
+ if main_py.exists():
103
+ console.print(" [dim]bots/starter/main.py already exists, skipping.[/dim]")
104
+ else:
105
+ main_py.write_text(STARTER_BOT.read_text())
106
+ console.print(" [green]+[/green] Created bots/starter/main.py")
107
+
108
+ # Done — show the full dev loop
109
+ console.print("\n[bold green]Ready![/bold green] Get started:\n")
110
+ if include_bot and include_maps:
111
+ run_cmd = "fcode run starter starter"
112
+ elif include_bot:
113
+ run_cmd = "fcode run starter starter <map_path>"
114
+ else:
115
+ run_cmd = "fcode run <bot_a> <bot_b> <map_path>"
116
+ console.print(f" 1. Run a match: {run_cmd}")
117
+ console.print(" 2. Watch replay: fcode watch replay.replay26")
118
+ console.print(" 3. Edit your bot: bots/starter/main.py" if include_bot else " 3. Write your bot: bots/<name>/main.py")
119
+ console.print(" 4. Submit: fcode submit bots/starter" if include_bot else " 4. Submit: fcode submit bots/<name>")
@@ -0,0 +1,112 @@
1
+ """fcode status — show current team, latest submission, and ladder rank."""
2
+
3
+ import click
4
+ from rich.console import Console
5
+
6
+ from fcode.api import api_get
7
+ from fcode.auth import load_credentials
8
+
9
+ console = Console()
10
+
11
+
12
+ def _get_rank_tier(rating: float, matches_played: int) -> str:
13
+ """Return rank tier name based on rating and matches played.
14
+
15
+ Keep in sync with shared/src/ranks.ts.
16
+ """
17
+ if matches_played < 100:
18
+ return "Unranked"
19
+ tiers = [
20
+ (3000, "Legendary Grandmaster"),
21
+ (2500, "Grandmaster"),
22
+ (2300, "Master"),
23
+ (2100, "Candidate Master"),
24
+ (1900, "Diamond"),
25
+ (1700, "Emerald"),
26
+ (1500, "Gold"),
27
+ (1300, "Silver"),
28
+ ]
29
+ for threshold, name in tiers:
30
+ if rating >= threshold:
31
+ return name
32
+ return "Bronze"
33
+
34
+
35
+ @click.command()
36
+ def status():
37
+ """Show your team, latest submission, and ladder rank."""
38
+ creds = load_credentials()
39
+ if not creds:
40
+ console.print("[red]Not logged in. Run: fcode login[/red]")
41
+ raise SystemExit(1)
42
+
43
+ user = creds.get("user", {})
44
+ team = creds.get("team")
45
+
46
+ console.print(f"\n[bold]{user.get('name', '?')}[/bold] ({user.get('email', '?')})")
47
+
48
+ if not team:
49
+ console.print(" Team: [dim]none[/dim]")
50
+ console.print()
51
+ return
52
+
53
+ try:
54
+ data = api_get(f"/api/teams/{team['id']}")
55
+ except SystemExit:
56
+ console.print(f" Team: [bold]{team.get('name', '?')}[/bold]")
57
+ console.print(f" [dim](Could not fetch live data)[/dim]")
58
+ console.print()
59
+ return
60
+
61
+ team_info = data.get("team", {})
62
+ rating = data.get("rating")
63
+ members = data.get("members", [])
64
+
65
+ console.print(f" Team: [bold]{team_info.get('name', '?')}[/bold] ({team_info.get('category', '?')})")
66
+
67
+ if rating:
68
+ r = rating.get("rating", 0)
69
+ mp = rating.get("matchesPlayed", 0)
70
+ tier = _get_rank_tier(r, mp)
71
+ console.print(f" Rating: {r:.0f} ({tier}) — {mp} matches played")
72
+ else:
73
+ console.print(" Rating: [dim]unrated[/dim]")
74
+
75
+ # Show ladder rank position
76
+ try:
77
+ ladder_data = api_get("/api/ladder")
78
+ rankings = ladder_data if isinstance(ladder_data, list) else ladder_data.get("rankings", [])
79
+ my_rank = next((i + 1 for i, r in enumerate(rankings) if r.get("teamId") == team["id"]), None)
80
+ if my_rank:
81
+ console.print(f" Rank: #{my_rank} of {len(rankings)}")
82
+ except SystemExit:
83
+ pass
84
+
85
+ # Show active submission
86
+ try:
87
+ sub_data = api_get("/api/submissions")
88
+ subs = sub_data.get("submissions", [])
89
+ active = next((s for s in subs if s.get("isActive")), None)
90
+ if active:
91
+ name_part = f" ({active['name']})" if active.get("name") else ""
92
+ console.print(f" Active bot: v{active.get('version', '?')}{name_part}")
93
+ elif subs:
94
+ console.print(" Active bot: [dim]none (no ready submission)[/dim]")
95
+ else:
96
+ console.print(" Active bot: [dim]no submissions yet[/dim]")
97
+ except SystemExit:
98
+ pass
99
+
100
+ # Show recent match record
101
+ try:
102
+ match_data = api_get("/api/matches", {"teamIds": team["id"], "limit": "10"})
103
+ recent = match_data.get("matches", [])
104
+ if recent:
105
+ wins = sum(1 for m in recent if m.get("winnerId") == team["id"])
106
+ losses = sum(1 for m in recent if m.get("winnerId") and m.get("winnerId") != team["id"])
107
+ console.print(f" Last 10: {wins}W {losses}L")
108
+ except SystemExit:
109
+ pass
110
+
111
+ console.print(f" Members: {', '.join(m.get('userName', '?') for m in members)}")
112
+ console.print()
@@ -0,0 +1,260 @@
1
+ """fcode submission — manage bot submissions (upload, list, activate, rename)."""
2
+
3
+ import io
4
+ import os
5
+ import zipfile
6
+ from pathlib import Path
7
+
8
+ import click
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+
12
+ from fcode.api import api_get, api_post, api_post_multipart
13
+
14
+ console = Console()
15
+
16
+
17
+ _JUNK_DIRS = {
18
+ "__pycache__",
19
+ ".git",
20
+ ".svn",
21
+ ".hg",
22
+ ".idea",
23
+ ".vscode",
24
+ ".mypy_cache",
25
+ ".pytest_cache",
26
+ ".ruff_cache",
27
+ "__MACOSX",
28
+ ".tox",
29
+ ".eggs",
30
+ ".nox",
31
+ }
32
+
33
+ _JUNK_FILES = {
34
+ ".DS_Store",
35
+ "Thumbs.db",
36
+ "desktop.ini",
37
+ ".thumbs",
38
+ "ehthumbs.db",
39
+ "ehthumbs_vista.db",
40
+ ".Spotlight-V100",
41
+ ".Trashes",
42
+ ".directory",
43
+ }
44
+
45
+ _JUNK_EXTENSIONS = {".pyc", ".pyo"}
46
+
47
+
48
+ def _is_junk(path: Path) -> bool:
49
+ if path.name in _JUNK_FILES or path.name.startswith("._"):
50
+ return True
51
+ if path.suffix in _JUNK_EXTENSIONS:
52
+ return True
53
+ return any(part in _JUNK_DIRS for part in path.parts)
54
+
55
+
56
+ def _make_zip(bot_path: Path) -> bytes:
57
+ """Create a zip from a directory or single file."""
58
+ if bot_path.is_dir():
59
+ main_py = bot_path / "main.py"
60
+ if not main_py.is_file():
61
+ raise click.BadParameter(f"Directory '{bot_path}' does not contain main.py")
62
+ buf = io.BytesIO()
63
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
64
+ for root, dirs, files in os.walk(bot_path):
65
+ dirs[:] = [d for d in dirs if d not in _JUNK_DIRS]
66
+ for f in files:
67
+ full = Path(root) / f
68
+ if _is_junk(full.relative_to(bot_path)):
69
+ continue
70
+ arcname = str(full.relative_to(bot_path))
71
+ zf.write(full, arcname)
72
+ return buf.getvalue()
73
+
74
+ if bot_path.suffix == ".zip":
75
+ return bot_path.read_bytes()
76
+
77
+ if bot_path.suffix == ".py":
78
+ buf = io.BytesIO()
79
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
80
+ zf.write(bot_path, "main.py")
81
+ return buf.getvalue()
82
+
83
+ raise click.BadParameter(f"Expected a directory, .py file, or .zip: '{bot_path}'")
84
+
85
+
86
+ @click.group()
87
+ def submission():
88
+ """Manage bot submissions (upload, list, activate, rename)."""
89
+ pass
90
+
91
+
92
+ @submission.command()
93
+ @click.argument("path", type=click.Path(exists=True))
94
+ @click.option("--name", "-n", default=None, help="Optional name for this submission")
95
+ def upload(path: str, name: str | None):
96
+ """Upload a bot to the platform.
97
+
98
+ PATH can be a directory (containing main.py), a single .py file,
99
+ or a .zip archive.
100
+ """
101
+ bot_path = Path(path).resolve()
102
+ console.print(f"[bold]Packaging {bot_path.name}...[/bold]")
103
+
104
+ try:
105
+ zip_bytes = _make_zip(bot_path)
106
+ except click.BadParameter as e:
107
+ console.print(f"[red]{e.format_message()}[/red]")
108
+ raise SystemExit(1)
109
+
110
+ size_kb = len(zip_bytes) / 1024
111
+ console.print(f" Zip size: {size_kb:.1f} KB")
112
+ console.print("[bold]Uploading...[/bold]")
113
+
114
+ text_fields: dict[str, str] = {}
115
+ if name:
116
+ text_fields["name"] = name
117
+
118
+ data = api_post_multipart(
119
+ "/api/submissions/upload",
120
+ files={"file": ("bot.zip", zip_bytes, "application/zip")},
121
+ text_fields=text_fields if text_fields else None,
122
+ )
123
+
124
+ sub = data.get("submission", {})
125
+ msg = f"[green]Submitted![/green] Version {sub.get('version', '?')} (ID: {sub.get('id', '?')})"
126
+ if name:
127
+ msg += f" — {name}"
128
+ console.print(msg)
129
+
130
+
131
+ @submission.command("list")
132
+ def list_submissions():
133
+ """List all submissions for your team."""
134
+ data = api_get("/api/submissions")
135
+ subs = data.get("submissions", [])
136
+
137
+ if not subs:
138
+ console.print("[dim]No submissions yet.[/dim]")
139
+ return
140
+
141
+ table = Table(show_header=True, header_style="bold")
142
+ table.add_column("Version", justify="right")
143
+ table.add_column("Name")
144
+ table.add_column("Status")
145
+ table.add_column("Active", justify="center")
146
+ table.add_column("Uploaded By")
147
+ table.add_column("Date", style="dim")
148
+
149
+ for s in subs:
150
+ version = f"v{s.get('version', '?')}"
151
+ name_display = s.get("name") or "[dim]—[/dim]"
152
+ status = s.get("status", "?")
153
+ if status == "ready":
154
+ status = f"[green]{status}[/green]"
155
+ elif status == "error":
156
+ status = f"[red]{status}[/red]"
157
+
158
+ active = "[green]Yes[/green]" if s.get("isActive") else "[dim]—[/dim]"
159
+ uploaded_by = s.get("submittedByName") or "[dim]—[/dim]"
160
+
161
+ date = ""
162
+ raw = s.get("uploadedAt")
163
+ if raw:
164
+ date = raw[:16].replace("T", " ")
165
+
166
+ table.add_row(version, name_display, status, active, uploaded_by, date)
167
+
168
+ console.print(table)
169
+
170
+
171
+ @submission.command()
172
+ @click.argument("version", type=int)
173
+ def activate(version: int):
174
+ """Set a submission as the active one used for ladder matches.
175
+
176
+ VERSION is the submission version number (e.g. 3).
177
+ """
178
+ # First, list submissions to find the ID for this version
179
+ data = api_get("/api/submissions")
180
+ subs = data.get("submissions", [])
181
+
182
+ target = next((s for s in subs if s.get("version") == version), None)
183
+ if not target:
184
+ console.print(f"[red]No submission found with version {version}.[/red]")
185
+ raise SystemExit(1)
186
+
187
+ if target.get("status") != "ready":
188
+ console.print(f"[red]Version {version} has status '{target.get('status')}' — only ready submissions can be activated.[/red]")
189
+ raise SystemExit(1)
190
+
191
+ if target.get("isActive"):
192
+ console.print(f"[dim]Version {version} is already active.[/dim]")
193
+ return
194
+
195
+ api_post("/api/submissions/activate", {"submissionId": target["id"]})
196
+ console.print(f"[green]Version {version} is now the active submission.[/green]")
197
+
198
+
199
+ @submission.command()
200
+ @click.argument("version", type=int)
201
+ @click.argument("name")
202
+ def rename(version: int, name: str):
203
+ """Rename a submission.
204
+
205
+ VERSION is the submission version number (e.g. 3).
206
+ NAME is the new name for the submission.
207
+ """
208
+ data = api_get("/api/submissions")
209
+ subs = data.get("submissions", [])
210
+
211
+ target = next((s for s in subs if s.get("version") == version), None)
212
+ if not target:
213
+ console.print(f"[red]No submission found with version {version}.[/red]")
214
+ raise SystemExit(1)
215
+
216
+ api_post("/api/submissions/rename", {"submissionId": target["id"], "name": name})
217
+ console.print(f"[green]Version {version} renamed to '{name}'.[/green]")
218
+
219
+
220
+ @submission.command()
221
+ @click.argument("version", required=False, default=None, type=int)
222
+ @click.option("--output", "-o", default=None, help="Output file path (default: v<VERSION>.zip)")
223
+ def download(version: int | None, output: str | None):
224
+ """Download a submission zip file.
225
+
226
+ VERSION is the submission version number. Defaults to the active submission.
227
+ """
228
+ import urllib.request
229
+
230
+ data = api_get("/api/submissions")
231
+ subs = data.get("submissions", [])
232
+
233
+ if not subs:
234
+ console.print("[red]No submissions found.[/red]")
235
+ raise SystemExit(1)
236
+
237
+ if version is not None:
238
+ target = next((s for s in subs if s.get("version") == version), None)
239
+ if not target:
240
+ console.print(f"[red]No submission found with version {version}.[/red]")
241
+ raise SystemExit(1)
242
+ else:
243
+ target = next((s for s in subs if s.get("isActive")), None)
244
+ if not target:
245
+ target = next((s for s in subs if s.get("status") == "ready"), None)
246
+ if not target:
247
+ console.print("[red]No ready submission to download.[/red]")
248
+ raise SystemExit(1)
249
+ version = target.get("version", 0)
250
+
251
+ dl_data = api_get("/api/submissions/download", {"submissionId": target["id"]})
252
+ url = dl_data.get("url")
253
+ if not url:
254
+ console.print("[red]Could not get download URL.[/red]")
255
+ raise SystemExit(1)
256
+
257
+ out_path = output or f"v{version}.zip"
258
+ urllib.request.urlretrieve(url, out_path)
259
+ name_part = f" ({target['name']})" if target.get("name") else ""
260
+ console.print(f"[green]Downloaded v{version}{name_part} to {out_path}[/green]")
@@ -0,0 +1,73 @@
1
+ """fcode team — search teams and view team profiles (replaces `teams`)."""
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
+
12
+ @click.group()
13
+ def team():
14
+ """Search teams and view team profiles."""
15
+ pass
16
+
17
+
18
+ @team.command()
19
+ @click.argument("query")
20
+ def search(query: str):
21
+ """Search for teams by name."""
22
+ from urllib.parse import urlencode
23
+ data = api_get(f"/api/teams/search?{urlencode({'q': query})}")
24
+
25
+ results = data.get("teams", [])
26
+ if not results:
27
+ console.print(f"[dim]No teams matching '{query}'.[/dim]")
28
+ return
29
+
30
+ table = Table(show_header=True, header_style="bold")
31
+ table.add_column("Team ID", style="dim")
32
+ table.add_column("Name")
33
+ table.add_column("Rating", justify="right")
34
+ table.add_column("Matches", justify="right")
35
+
36
+ for t in results:
37
+ rating = f"{t.get('rating', 0):.0f}"
38
+ table.add_row(
39
+ t.get("teamId", "?"),
40
+ t.get("teamName", "?"),
41
+ rating,
42
+ str(t.get("matchesPlayed", 0)),
43
+ )
44
+
45
+ console.print(table)
46
+
47
+
48
+ @team.command()
49
+ @click.argument("team_id")
50
+ def info(team_id: str):
51
+ """View detailed info for a team by ID."""
52
+ data = api_get(f"/api/teams/{team_id}")
53
+
54
+ team_data = data.get("team", {})
55
+ members = data.get("members", [])
56
+ rating = data.get("rating")
57
+
58
+ console.print(f"\n[bold]{team_data.get('name', '?')}[/bold] [dim]({team_data.get('id', '?')})[/dim]")
59
+
60
+
61
+ if rating:
62
+ console.print(f" Rating: {rating.get('rating', 0):.0f}")
63
+ console.print(f" Matches: {rating.get('matchesPlayed', 0)}")
64
+ else:
65
+ console.print(" Rating: unrated")
66
+
67
+ if members:
68
+ console.print("\n Members:")
69
+ for m in members:
70
+ role_badge = " [dim](owner)[/dim]" if m.get("role") == "owner" else ""
71
+ console.print(f" - {m.get('userName', '?')}{role_badge}")
72
+
73
+ console.print()
@@ -0,0 +1,73 @@
1
+ """fcode teams — search teams and view team info."""
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
+
12
+ @click.group()
13
+ def teams():
14
+ """Search teams and view team profiles."""
15
+ pass
16
+
17
+
18
+ @teams.command()
19
+ @click.argument("query")
20
+ def search(query: str):
21
+ """Search for teams by name."""
22
+ from urllib.parse import urlencode
23
+ data = api_get(f"/api/teams/search?{urlencode({'q': query})}")
24
+
25
+ results = data.get("teams", [])
26
+ if not results:
27
+ console.print(f"[dim]No teams matching '{query}'.[/dim]")
28
+ return
29
+
30
+ table = Table(show_header=True, header_style="bold")
31
+ table.add_column("Team ID", style="dim")
32
+ table.add_column("Name")
33
+ table.add_column("Rating", justify="right")
34
+ table.add_column("Matches", justify="right")
35
+
36
+ for t in results:
37
+ rating = f"{t.get('rating', 0):.0f}"
38
+ table.add_row(
39
+ t.get("teamId", "?"),
40
+ t.get("teamName", "?"),
41
+ rating,
42
+ str(t.get("matchesPlayed", 0)),
43
+ )
44
+
45
+ console.print(table)
46
+
47
+
48
+ @teams.command()
49
+ @click.argument("team_id")
50
+ def info(team_id: str):
51
+ """View detailed info for a team by ID."""
52
+ data = api_get(f"/api/teams/{team_id}")
53
+
54
+ team = data.get("team", {})
55
+ members = data.get("members", [])
56
+ rating = data.get("rating")
57
+
58
+ console.print(f"\n[bold]{team.get('name', '?')}[/bold] [dim]({team.get('id', '?')})[/dim]")
59
+
60
+
61
+ if rating:
62
+ console.print(f" Rating: {rating.get('rating', 0):.0f}")
63
+ console.print(f" Matches: {rating.get('matchesPlayed', 0)}")
64
+ else:
65
+ console.print(" Rating: unrated")
66
+
67
+ if members:
68
+ console.print("\n Members:")
69
+ for m in members:
70
+ role_badge = " [dim](owner)[/dim]" if m.get("role") == "owner" else ""
71
+ console.print(f" - {m.get('userName', '?')}{role_badge}")
72
+
73
+ console.print()
fcode/commands/test.py ADDED
@@ -0,0 +1,51 @@
1
+ """fcode unrated — request an unrated match against another team."""
2
+
3
+ import click
4
+ from rich.console import Console
5
+
6
+ from fcode.api import api_post
7
+
8
+ console = Console()
9
+
10
+
11
+ def _show_unrated(opponent_id: str, source_match: str | None, maps: tuple[str, ...] | None):
12
+ """Core implementation for requesting an unrated match."""
13
+ body: dict = {"opponentTeamId": opponent_id}
14
+ if source_match:
15
+ body["sourceMatchId"] = source_match
16
+ if maps:
17
+ body["mapNames"] = list(maps[:5])
18
+
19
+ data = api_post("/api/matches/unrated", body)
20
+
21
+ match_id = data.get("matchId")
22
+ if not match_id:
23
+ console.print("[red]Unexpected response from server.[/red]")
24
+ raise SystemExit(1)
25
+
26
+ from fcode.auth import get_api_url
27
+ msg = f"[green]Unrated match queued![/green] Match ID: {match_id}"
28
+ if source_match:
29
+ msg += f"\n Opponent version from match: {source_match}"
30
+ if maps:
31
+ msg += f"\n Maps: {', '.join(maps[:5])}"
32
+ console.print(msg)
33
+ console.print(f" View at: {get_api_url()}/match/{match_id}")
34
+
35
+
36
+ @click.command("unrated")
37
+ @click.argument("opponent_id")
38
+ @click.option("--match", "source_match", default=None, help="Use opponent's submission from this match ID")
39
+ @click.option("--map", "maps", multiple=True, help="Map name (repeatable, up to 5). Omit for random.")
40
+ def unrated(opponent_id: str, source_match: str | None, maps: tuple[str, ...]):
41
+ """Request an unrated match against OPPONENT_ID (your team vs theirs).
42
+
43
+ Use `fcode team search` to find team IDs.
44
+
45
+ With --match, the opponent uses whichever submission version they had
46
+ in that match, instead of their latest.
47
+
48
+ With --map, specify which maps to play (up to 5). Omit for random selection.
49
+ Example: fcode unrated <id> --map arena --map fortress --map galaxy
50
+ """
51
+ _show_unrated(opponent_id, source_match, maps or None)