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
|
@@ -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>")
|
fcode/commands/status.py
ADDED
|
@@ -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()
|
fcode/commands/teams.py
ADDED
|
@@ -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)
|