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.
- fcode/__init__.py +3 -0
- fcode/_types.py +655 -0
- fcode/api.py +146 -0
- fcode/auth.py +49 -0
- fcode/cli.py +127 -0
- fcode/commands/__init__.py +0 -0
- fcode/commands/init.py +29 -0
- fcode/commands/ladder.py +74 -0
- fcode/commands/login.py +135 -0
- fcode/commands/logout.py +21 -0
- fcode/commands/map_editor.py +91 -0
- fcode/commands/match_detail.py +173 -0
- fcode/commands/match_group.py +163 -0
- fcode/commands/matches.py +127 -0
- fcode/commands/run.py +186 -0
- fcode/commands/starter.py +119 -0
- fcode/commands/status.py +112 -0
- fcode/commands/submission.py +260 -0
- fcode/commands/team_group.py +73 -0
- fcode/commands/teams.py +73 -0
- fcode/commands/test.py +51 -0
- fcode/commands/test_matches.py +59 -0
- fcode/commands/test_run.py +125 -0
- fcode/commands/watch.py +123 -0
- fcode/compat.py +36 -0
- fcode/config.py +47 -0
- fcode/data/__init__.py +0 -0
- fcode/data/docs/spec.md +290 -0
- fcode/data/maps/__init__.py +0 -0
- fcode/data/maps/atoll.map26 +0 -0
- fcode/data/maps/aurora.map26 +0 -0
- fcode/data/maps/crossfire.map26 +0 -0
- fcode/data/maps/duel.map26 +0 -0
- fcode/data/maps/fjord.map26 +0 -0
- fcode/data/maps/hive.map26 +0 -0
- fcode/data/maps/longship.map26 +0 -0
- fcode/data/maps/pinch.map26 +0 -0
- fcode/data/maps/quarry.map26 +0 -0
- fcode/data/maps/runestone.map26 +0 -0
- fcode/data/maps/skerry.map26 +0 -0
- fcode/data/maps/sprint.map26 +0 -0
- fcode/data/maps/strait.map26 +0 -0
- fcode/data/maps/twins.map26 +0 -0
- fcode/data/maps/vault.map26 +0 -0
- fcode/data/starter_bot.py +452 -0
- fcode/data/visualiser/.directory +9 -0
- fcode/data/visualiser/Builderbot_med.png +0 -0
- fcode/data/visualiser/armoured_conveyor_gold.png +0 -0
- fcode/data/visualiser/armoured_conveyor_silver.png +0 -0
- fcode/data/visualiser/assets/app-BiRa_5u6.css +1 -0
- fcode/data/visualiser/assets/app-CK52NxpR.js +86 -0
- fcode/data/visualiser/assets/main-COIx42iq.js +1 -0
- fcode/data/visualiser/assets/map-editor-BDLYjgel.js +1 -0
- fcode/data/visualiser/axionite_ore.png +0 -0
- fcode/data/visualiser/axionite_processed.png +0 -0
- fcode/data/visualiser/axionite_raw.png +0 -0
- fcode/data/visualiser/barrier_gold.png +0 -0
- fcode/data/visualiser/barrier_silver.png +0 -0
- fcode/data/visualiser/base_gold.png +0 -0
- fcode/data/visualiser/base_silver.png +0 -0
- fcode/data/visualiser/battlecodetitle-orange.webp +0 -0
- fcode/data/visualiser/bg-light.png +0 -0
- fcode/data/visualiser/bg.png +0 -0
- fcode/data/visualiser/breach_e_gold.png +0 -0
- fcode/data/visualiser/breach_e_silver.png +0 -0
- fcode/data/visualiser/breach_n_gold.png +0 -0
- fcode/data/visualiser/breach_n_silver.png +0 -0
- fcode/data/visualiser/breach_ne_gold.png +0 -0
- fcode/data/visualiser/breach_ne_silver.png +0 -0
- fcode/data/visualiser/breach_nw_gold.png +0 -0
- fcode/data/visualiser/breach_nw_silver.png +0 -0
- fcode/data/visualiser/breach_s_gold.png +0 -0
- fcode/data/visualiser/breach_s_silver.png +0 -0
- fcode/data/visualiser/breach_se_gold.png +0 -0
- fcode/data/visualiser/breach_se_silver.png +0 -0
- fcode/data/visualiser/breach_sw_gold.png +0 -0
- fcode/data/visualiser/breach_sw_silver.png +0 -0
- fcode/data/visualiser/bridge_gold.png +0 -0
- fcode/data/visualiser/bridge_silver.png +0 -0
- fcode/data/visualiser/bridge_stand_gold.png +0 -0
- fcode/data/visualiser/bridge_stand_silver.png +0 -0
- fcode/data/visualiser/builderbot_back_gold.png +0 -0
- fcode/data/visualiser/builderbot_back_silver.png +0 -0
- fcode/data/visualiser/builderbot_front_gold.png +0 -0
- fcode/data/visualiser/builderbot_front_silver.png +0 -0
- fcode/data/visualiser/builderbot_side_gold.png +0 -0
- fcode/data/visualiser/builderbot_side_silver.png +0 -0
- fcode/data/visualiser/conveyor_gold.png +0 -0
- fcode/data/visualiser/conveyor_silver.png +0 -0
- fcode/data/visualiser/dimetric/Dimetric_Tile_Cube.png +0 -0
- fcode/data/visualiser/dimetric/Dimetric_Tile_Floor_Footprint.png +0 -0
- fcode/data/visualiser/dimetric/barrier_team1.png +0 -0
- fcode/data/visualiser/dimetric/barrier_team2.png +0 -0
- fcode/data/visualiser/dimetric/base_team1.png +0 -0
- fcode/data/visualiser/dimetric/base_team2.png +0 -0
- fcode/data/visualiser/dimetric/bg.png +0 -0
- fcode/data/visualiser/dimetric/bridge_stand_team1.png +0 -0
- fcode/data/visualiser/dimetric/bridge_stand_team2.png +0 -0
- fcode/data/visualiser/dimetric/bridge_team1.png +0 -0
- fcode/data/visualiser/dimetric/bridge_team2.png +0 -0
- fcode/data/visualiser/dimetric/builderbot_e_team1.png +0 -0
- fcode/data/visualiser/dimetric/builderbot_e_team2.png +0 -0
- fcode/data/visualiser/dimetric/builderbot_n_team1.png +0 -0
- fcode/data/visualiser/dimetric/builderbot_n_team2.png +0 -0
- fcode/data/visualiser/dimetric/builderbot_ne_team1.png +0 -0
- fcode/data/visualiser/dimetric/builderbot_ne_team2.png +0 -0
- fcode/data/visualiser/dimetric/builderbot_nw_team1.png +0 -0
- fcode/data/visualiser/dimetric/builderbot_nw_team2.png +0 -0
- fcode/data/visualiser/dimetric/builderbot_s_team1.png +0 -0
- fcode/data/visualiser/dimetric/builderbot_s_team2.png +0 -0
- fcode/data/visualiser/dimetric/builderbot_se_team1.png +0 -0
- fcode/data/visualiser/dimetric/builderbot_se_team2.png +0 -0
- fcode/data/visualiser/dimetric/builderbot_sw_team1.png +0 -0
- fcode/data/visualiser/dimetric/builderbot_sw_team2.png +0 -0
- fcode/data/visualiser/dimetric/builderbot_w_team1.png +0 -0
- fcode/data/visualiser/dimetric/builderbot_w_team2.png +0 -0
- fcode/data/visualiser/dimetric/conveyor_ne_team1.png +0 -0
- fcode/data/visualiser/dimetric/conveyor_ne_team2.png +0 -0
- fcode/data/visualiser/dimetric/conveyor_nw_team1.png +0 -0
- fcode/data/visualiser/dimetric/conveyor_nw_team2.png +0 -0
- fcode/data/visualiser/dimetric/conveyor_se_team1.png +0 -0
- fcode/data/visualiser/dimetric/conveyor_se_team2.png +0 -0
- fcode/data/visualiser/dimetric/conveyor_sw_team1.png +0 -0
- fcode/data/visualiser/dimetric/conveyor_sw_team2.png +0 -0
- fcode/data/visualiser/dimetric/gunner_e_team1.png +0 -0
- fcode/data/visualiser/dimetric/gunner_e_team2.png +0 -0
- fcode/data/visualiser/dimetric/gunner_n_team1.png +0 -0
- fcode/data/visualiser/dimetric/gunner_n_team2.png +0 -0
- fcode/data/visualiser/dimetric/gunner_ne_team1.png +0 -0
- fcode/data/visualiser/dimetric/gunner_ne_team2.png +0 -0
- fcode/data/visualiser/dimetric/gunner_nw_team1.png +0 -0
- fcode/data/visualiser/dimetric/gunner_nw_team2.png +0 -0
- fcode/data/visualiser/dimetric/gunner_s_team1.png +0 -0
- fcode/data/visualiser/dimetric/gunner_s_team2.png +0 -0
- fcode/data/visualiser/dimetric/gunner_se_team1.png +0 -0
- fcode/data/visualiser/dimetric/gunner_se_team2.png +0 -0
- fcode/data/visualiser/dimetric/gunner_sw_team1.png +0 -0
- fcode/data/visualiser/dimetric/gunner_sw_team2.png +0 -0
- fcode/data/visualiser/dimetric/gunner_w_team1.png +0 -0
- fcode/data/visualiser/dimetric/gunner_w_team2.png +0 -0
- fcode/data/visualiser/dimetric/harvester_team1.png +0 -0
- fcode/data/visualiser/dimetric/harvester_team2.png +0 -0
- fcode/data/visualiser/dimetric/launcher_team1_ne.png +0 -0
- fcode/data/visualiser/dimetric/launcher_team1_nw.png +0 -0
- fcode/data/visualiser/dimetric/launcher_team1_se.png +0 -0
- fcode/data/visualiser/dimetric/launcher_team1_sw.png +0 -0
- fcode/data/visualiser/dimetric/launcher_team2_ne.png +0 -0
- fcode/data/visualiser/dimetric/launcher_team2_nw.png +0 -0
- fcode/data/visualiser/dimetric/launcher_team2_se.png +0 -0
- fcode/data/visualiser/dimetric/launcher_team2_sw.png +0 -0
- fcode/data/visualiser/dimetric/natural_wall.png +0 -0
- fcode/data/visualiser/dimetric/natural_wall_ne.png +0 -0
- fcode/data/visualiser/dimetric/natural_wall_nw.png +0 -0
- fcode/data/visualiser/dimetric/natural_wall_se.png +0 -0
- fcode/data/visualiser/dimetric/natural_wall_sw.png +0 -0
- fcode/data/visualiser/dimetric/road_team1.png +0 -0
- fcode/data/visualiser/dimetric/road_team2.png +0 -0
- fcode/data/visualiser/dimetric/sentinel_e_team1.png +0 -0
- fcode/data/visualiser/dimetric/sentinel_e_team2.png +0 -0
- fcode/data/visualiser/dimetric/sentinel_n_team1.png +0 -0
- fcode/data/visualiser/dimetric/sentinel_n_team2.png +0 -0
- fcode/data/visualiser/dimetric/sentinel_ne_team1.png +0 -0
- fcode/data/visualiser/dimetric/sentinel_ne_team2.png +0 -0
- fcode/data/visualiser/dimetric/sentinel_nw_team1.png +0 -0
- fcode/data/visualiser/dimetric/sentinel_nw_team2.png +0 -0
- fcode/data/visualiser/dimetric/sentinel_s_team1.png +0 -0
- fcode/data/visualiser/dimetric/sentinel_s_team2.png +0 -0
- fcode/data/visualiser/dimetric/sentinel_se_team1.png +0 -0
- fcode/data/visualiser/dimetric/sentinel_se_team2.png +0 -0
- fcode/data/visualiser/dimetric/sentinel_sw_team1.png +0 -0
- fcode/data/visualiser/dimetric/sentinel_sw_team2.png +0 -0
- fcode/data/visualiser/dimetric/sentinel_w_team1.png +0 -0
- fcode/data/visualiser/dimetric/sentinel_w_team2.png +0 -0
- fcode/data/visualiser/dimetric/splitter_ne_team1.png +0 -0
- fcode/data/visualiser/dimetric/splitter_ne_team2.png +0 -0
- fcode/data/visualiser/dimetric/splitter_nw_team1.png +0 -0
- fcode/data/visualiser/dimetric/splitter_nw_team2.png +0 -0
- fcode/data/visualiser/dimetric/splitter_se_team1.png +0 -0
- fcode/data/visualiser/dimetric/splitter_se_team2.png +0 -0
- fcode/data/visualiser/dimetric/splitter_sw_team1.png +0 -0
- fcode/data/visualiser/dimetric/splitter_sw_team2.png +0 -0
- fcode/data/visualiser/dimetric/titanium.png +0 -0
- fcode/data/visualiser/dimetric/titanium_ore.png +0 -0
- fcode/data/visualiser/fonts/HKGrotesk-Regular.otf +0 -0
- fcode/data/visualiser/foundry_gold.png +0 -0
- fcode/data/visualiser/foundry_silver.png +0 -0
- fcode/data/visualiser/gunner_n_gold.png +0 -0
- fcode/data/visualiser/gunner_n_silver.png +0 -0
- fcode/data/visualiser/gunner_ne_gold.png +0 -0
- fcode/data/visualiser/gunner_ne_silver.png +0 -0
- fcode/data/visualiser/gunner_nw_gold.png +0 -0
- fcode/data/visualiser/gunner_nw_silver.png +0 -0
- fcode/data/visualiser/gunner_s_gold.png +0 -0
- fcode/data/visualiser/gunner_s_silver.png +0 -0
- fcode/data/visualiser/gunner_se_gold.png +0 -0
- fcode/data/visualiser/gunner_se_silver.png +0 -0
- fcode/data/visualiser/gunner_sw_gold.png +0 -0
- fcode/data/visualiser/gunner_sw_silver.png +0 -0
- fcode/data/visualiser/gunner_w_gold.png +0 -0
- fcode/data/visualiser/gunner_w_silver.png +0 -0
- fcode/data/visualiser/harvester_gold.png +0 -0
- fcode/data/visualiser/harvester_silver.png +0 -0
- fcode/data/visualiser/index.html +20 -0
- fcode/data/visualiser/launcher_gold.png +0 -0
- fcode/data/visualiser/launcher_silver.png +0 -0
- fcode/data/visualiser/map-editor.html +20 -0
- fcode/data/visualiser/marker_gold.png +0 -0
- fcode/data/visualiser/marker_silver.png +0 -0
- fcode/data/visualiser/natural_wall.jpg +0 -0
- fcode/data/visualiser/road_gold.png +0 -0
- fcode/data/visualiser/road_silver.png +0 -0
- fcode/data/visualiser/sentinel_e_gold.png +0 -0
- fcode/data/visualiser/sentinel_e_silver.png +0 -0
- fcode/data/visualiser/sentinel_n_gold.png +0 -0
- fcode/data/visualiser/sentinel_n_silver.png +0 -0
- fcode/data/visualiser/sentinel_ne_gold.png +0 -0
- fcode/data/visualiser/sentinel_ne_silver.png +0 -0
- fcode/data/visualiser/sentinel_nw_gold.png +0 -0
- fcode/data/visualiser/sentinel_nw_silver.png +0 -0
- fcode/data/visualiser/sentinel_s_gold.png +0 -0
- fcode/data/visualiser/sentinel_s_silver.png +0 -0
- fcode/data/visualiser/sentinel_se_gold.png +0 -0
- fcode/data/visualiser/sentinel_se_silver.png +0 -0
- fcode/data/visualiser/sentinel_sw_gold.png +0 -0
- fcode/data/visualiser/sentinel_sw_silver.png +0 -0
- fcode/data/visualiser/splitter.png +0 -0
- fcode/data/visualiser/splitter_e_gold.png +0 -0
- fcode/data/visualiser/splitter_e_silver.png +0 -0
- fcode/data/visualiser/splitter_n_gold.png +0 -0
- fcode/data/visualiser/splitter_n_silver.png +0 -0
- fcode/data/visualiser/splitter_s_gold.png +0 -0
- fcode/data/visualiser/splitter_s_silver.png +0 -0
- fcode/data/visualiser/splitter_w_gold.png +0 -0
- fcode/data/visualiser/splitter_w_silver.png +0 -0
- fcode/data/visualiser/template02.png +0 -0
- fcode/data/visualiser/titanium.png +0 -0
- fcode/data/visualiser/titanium_ore.png +0 -0
- fcode/fcode_engine.cp312-win_amd64.pyd +0 -0
- fcode/py.typed +0 -0
- fcode/version_check.py +72 -0
- fcode-2.2.0.dist-info/METADATA +7 -0
- fcode-2.2.0.dist-info/RECORD +245 -0
- fcode-2.2.0.dist-info/WHEEL +4 -0
- fcode-2.2.0.dist-info/entry_points.txt +2 -0
- 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]")
|
fcode/commands/ladder.py
ADDED
|
@@ -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]")
|
fcode/commands/login.py
ADDED
|
@@ -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]")
|
fcode/commands/logout.py
ADDED
|
@@ -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()
|