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,173 @@
|
|
|
1
|
+
"""fcode match — view detailed match info including individual games."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.table import Table
|
|
6
|
+
|
|
7
|
+
from fcode.api import api_get
|
|
8
|
+
|
|
9
|
+
console = Console()
|
|
10
|
+
|
|
11
|
+
_STAGE_LABELS = {
|
|
12
|
+
"starting": "Starting...",
|
|
13
|
+
"downloading_bots": "Downloading bots...",
|
|
14
|
+
"uploading_replay": "Uploading replay...",
|
|
15
|
+
"processing_result": "Processing...",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
_CONDITION_LABELS = {
|
|
19
|
+
"core_destroyed": "Core Destroyed",
|
|
20
|
+
"resigned": "Resigned",
|
|
21
|
+
"resources": "Resource Victory",
|
|
22
|
+
"titanium_collected": "Titanium Collected",
|
|
23
|
+
"harvesters": "Harvesters",
|
|
24
|
+
"titanium_stored": "Titanium Stored",
|
|
25
|
+
"coinflip": "Coinflip",
|
|
26
|
+
"timeout": "Timeout",
|
|
27
|
+
"error": "Error",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _format_stage(stage: str) -> str:
|
|
32
|
+
if stage in _STAGE_LABELS:
|
|
33
|
+
return _STAGE_LABELS[stage]
|
|
34
|
+
if stage.startswith("running_game_"):
|
|
35
|
+
n = stage.split("_")[2]
|
|
36
|
+
return f"Game {n}/5"
|
|
37
|
+
return stage
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _show_match_detail(match_id: str):
|
|
41
|
+
"""Core implementation for showing match detail."""
|
|
42
|
+
data = api_get(f"/api/matches/{match_id}")
|
|
43
|
+
m = data.get("match")
|
|
44
|
+
games = data.get("games", [])
|
|
45
|
+
|
|
46
|
+
if not m:
|
|
47
|
+
console.print("[red]Match not found.[/red]")
|
|
48
|
+
raise SystemExit(1)
|
|
49
|
+
|
|
50
|
+
status = m.get("status", "?")
|
|
51
|
+
triggered = m.get("triggeredBy", "?")
|
|
52
|
+
rated = "rated" if m.get("rated") else "unrated"
|
|
53
|
+
score = f"{m.get('scoreA', 0)}-{m.get('scoreB', 0)}"
|
|
54
|
+
|
|
55
|
+
console.print(f"\n[bold]Match {match_id}[/bold]")
|
|
56
|
+
|
|
57
|
+
stage = m.get("stage") or ""
|
|
58
|
+
if status == "complete":
|
|
59
|
+
status_display = f"[green]{status}[/green]"
|
|
60
|
+
elif status == "error":
|
|
61
|
+
status_display = f"[red]{status}[/red]"
|
|
62
|
+
elif status == "running":
|
|
63
|
+
stage_label = _format_stage(stage) if stage else ""
|
|
64
|
+
status_display = f"[yellow]{status}[/yellow]"
|
|
65
|
+
if stage_label:
|
|
66
|
+
status_display += f" ({stage_label})"
|
|
67
|
+
elif status == "queued":
|
|
68
|
+
status_display = f"[dim]{status}[/dim]"
|
|
69
|
+
else:
|
|
70
|
+
status_display = status
|
|
71
|
+
console.print(f" Status: {status_display}")
|
|
72
|
+
console.print(f" Type: {triggered} ({rated})")
|
|
73
|
+
|
|
74
|
+
team_a = m.get("teamAName") or "Bot A"
|
|
75
|
+
team_b = m.get("teamBName") or "Bot B"
|
|
76
|
+
team_a_id = m.get("teamAId")
|
|
77
|
+
team_b_id = m.get("teamBId")
|
|
78
|
+
winner_id = m.get("winnerId")
|
|
79
|
+
|
|
80
|
+
score_a = m.get("scoreA", 0)
|
|
81
|
+
score_b = m.get("scoreB", 0)
|
|
82
|
+
a_is_winner = (winner_id and winner_id == team_a_id) or (not winner_id and score_a > score_b)
|
|
83
|
+
b_is_winner = (winner_id and winner_id == team_b_id) or (not winner_id and score_b > score_a)
|
|
84
|
+
|
|
85
|
+
a_id_display = f" ({team_a_id})" if team_a_id else ""
|
|
86
|
+
a_line = f" Team A: [bold]{team_a}[/bold]{a_id_display}"
|
|
87
|
+
if a_is_winner:
|
|
88
|
+
a_line += " [green]<- winner[/green]"
|
|
89
|
+
source_a = m.get("sourceMatchAId")
|
|
90
|
+
if source_a:
|
|
91
|
+
a_line += f"\n [dim]submission from match {source_a}[/dim]"
|
|
92
|
+
|
|
93
|
+
b_id_display = f" ({team_b_id})" if team_b_id else ""
|
|
94
|
+
b_line = f" Team B: [bold]{team_b}[/bold]{b_id_display}"
|
|
95
|
+
if b_is_winner:
|
|
96
|
+
b_line += " [green]<- winner[/green]"
|
|
97
|
+
source_b = m.get("sourceMatchBId")
|
|
98
|
+
if source_b:
|
|
99
|
+
b_line += f"\n [dim]submission from match {source_b}[/dim]"
|
|
100
|
+
|
|
101
|
+
console.print(a_line)
|
|
102
|
+
console.print(b_line)
|
|
103
|
+
console.print(f" Score: [bold]{score}[/bold]")
|
|
104
|
+
|
|
105
|
+
elo_a = m.get("eloDeltaA")
|
|
106
|
+
elo_b = m.get("eloDeltaB")
|
|
107
|
+
if elo_a is not None and elo_b is not None:
|
|
108
|
+
sign_a = "+" if elo_a >= 0 else ""
|
|
109
|
+
sign_b = "+" if elo_b >= 0 else ""
|
|
110
|
+
console.print(f" ELO: A: {sign_a}{elo_a:.1f} B: {sign_b}{elo_b:.1f}")
|
|
111
|
+
|
|
112
|
+
created = m.get("createdAt", "")
|
|
113
|
+
if created:
|
|
114
|
+
console.print(f" Created: {created[:19].replace('T', ' ')}")
|
|
115
|
+
completed = m.get("completedAt", "")
|
|
116
|
+
if completed:
|
|
117
|
+
console.print(f" Done: {completed[:19].replace('T', ' ')}")
|
|
118
|
+
|
|
119
|
+
error_msg = m.get("errorMessage")
|
|
120
|
+
if error_msg:
|
|
121
|
+
# Decode escaped newlines/tabs from the stored error string
|
|
122
|
+
rendered = error_msg.replace("\\n", "\n").replace("\\t", "\t")
|
|
123
|
+
console.print()
|
|
124
|
+
if status == "error":
|
|
125
|
+
console.print("[red]Error:[/red]")
|
|
126
|
+
for line in rendered.splitlines():
|
|
127
|
+
console.print(f" [red]{line}[/red]")
|
|
128
|
+
else:
|
|
129
|
+
console.print(f" [dim]{rendered}[/dim]")
|
|
130
|
+
|
|
131
|
+
if not games:
|
|
132
|
+
if status == "queued":
|
|
133
|
+
console.print("\n [dim]Match is queued — waiting for a runner...[/dim]\n")
|
|
134
|
+
elif status == "running":
|
|
135
|
+
console.print("\n [dim]Match is running...[/dim]\n")
|
|
136
|
+
else:
|
|
137
|
+
console.print("\n [dim]No game data.[/dim]\n")
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
console.print()
|
|
141
|
+
table = Table(show_header=True, header_style="bold")
|
|
142
|
+
table.add_column("#", justify="right")
|
|
143
|
+
table.add_column("Map")
|
|
144
|
+
table.add_column("Winner", justify="center")
|
|
145
|
+
table.add_column("Condition")
|
|
146
|
+
table.add_column("Turns", justify="right")
|
|
147
|
+
|
|
148
|
+
for g in games:
|
|
149
|
+
game_num = str(g.get("gameNumber", "?"))
|
|
150
|
+
map_name = g.get("mapName", "?")
|
|
151
|
+
g_winner = g.get("winnerId")
|
|
152
|
+
|
|
153
|
+
if g_winner and g_winner == team_a_id:
|
|
154
|
+
winner = f"[green]A ({team_a})[/green]"
|
|
155
|
+
elif g_winner and g_winner == team_b_id:
|
|
156
|
+
winner = f"[green]B ({team_b})[/green]"
|
|
157
|
+
else:
|
|
158
|
+
winner = "[dim]--[/dim]"
|
|
159
|
+
|
|
160
|
+
raw_condition = g.get("winCondition", "?")
|
|
161
|
+
condition = _CONDITION_LABELS.get(raw_condition, raw_condition)
|
|
162
|
+
turns = str(g.get("turnsPlayed", "?"))
|
|
163
|
+
table.add_row(game_num, map_name, winner, condition, turns)
|
|
164
|
+
|
|
165
|
+
console.print(table)
|
|
166
|
+
console.print()
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@click.command("match")
|
|
170
|
+
@click.argument("match_id")
|
|
171
|
+
def match_detail(match_id: str):
|
|
172
|
+
"""View detailed info for a match, including individual games."""
|
|
173
|
+
_show_match_detail(match_id)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""fcode match — view, list, and manage matches."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
|
|
6
|
+
from fcode.compat import SmartGroup
|
|
7
|
+
|
|
8
|
+
console = Console()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@click.group(cls=SmartGroup, default_cmd="info")
|
|
12
|
+
def match():
|
|
13
|
+
"""View, list, and manage matches."""
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# --- info (default subcommand) ---
|
|
18
|
+
from fcode.commands.match_detail import _show_match_detail
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@match.command()
|
|
22
|
+
@click.argument("match_id")
|
|
23
|
+
def info(match_id: str):
|
|
24
|
+
"""View detailed info for a match, including individual games."""
|
|
25
|
+
_show_match_detail(match_id)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# --- list ---
|
|
29
|
+
from fcode.commands.matches import _show_matches
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@match.command("list")
|
|
33
|
+
@click.option(
|
|
34
|
+
"--type",
|
|
35
|
+
"match_type",
|
|
36
|
+
default=None,
|
|
37
|
+
type=click.Choice(["ladder", "unrated"]),
|
|
38
|
+
help="Filter by match type (shows all if omitted)",
|
|
39
|
+
)
|
|
40
|
+
@click.option("--team", default=None, help="Filter by team name or team ID")
|
|
41
|
+
@click.option("--mine", is_flag=True, help="Show only your team's matches")
|
|
42
|
+
@click.option("--limit", default=20, type=int, help="Number of matches to show (max 100)")
|
|
43
|
+
@click.option("--cursor", default=None, help="Pagination cursor (from previous page)")
|
|
44
|
+
def list_matches(match_type, team, mine, limit, cursor):
|
|
45
|
+
"""List recent matches. Use --type to filter by ladder or unrated."""
|
|
46
|
+
_show_matches(match_type, team, limit, cursor, mine=mine)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# --- unrated ---
|
|
50
|
+
from fcode.commands.test import _show_unrated
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@match.command()
|
|
54
|
+
@click.argument("opponent_id")
|
|
55
|
+
@click.option("--match", "source_match", default=None, help="Use opponent's submission from this match ID")
|
|
56
|
+
@click.option("--map", "maps", multiple=True, help="Map name (repeatable, up to 5). Omit for random.")
|
|
57
|
+
def unrated(opponent_id: str, source_match: str | None, maps: tuple[str, ...]):
|
|
58
|
+
"""Request an unrated match against OPPONENT_ID.
|
|
59
|
+
|
|
60
|
+
Use `fcode team search` to find team IDs.
|
|
61
|
+
|
|
62
|
+
With --map, specify which maps to play (up to 5). Omit for random selection.
|
|
63
|
+
Example: fcode match unrated <id> --map arena --map fortress
|
|
64
|
+
"""
|
|
65
|
+
_show_unrated(opponent_id, source_match, maps if maps else None)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# --- test ---
|
|
69
|
+
from fcode.commands.test_run import _show_test_run
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@match.command("test")
|
|
73
|
+
@click.argument("bot_a")
|
|
74
|
+
@click.argument("bot_b")
|
|
75
|
+
@click.argument("maps", nargs=-1)
|
|
76
|
+
def test(bot_a: str, bot_b: str, maps: tuple[str, ...]):
|
|
77
|
+
"""Run a remote test match between two local bots with TLE enforcement.
|
|
78
|
+
|
|
79
|
+
\b
|
|
80
|
+
BOT_A and BOT_B can be directories (containing main.py), single .py files,
|
|
81
|
+
or .zip archives.
|
|
82
|
+
MAPS are optional .map26 files — one per game. If omitted, 5 random maps.
|
|
83
|
+
"""
|
|
84
|
+
_show_test_run(bot_a, bot_b, maps)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# --- replay ---
|
|
88
|
+
|
|
89
|
+
@match.command()
|
|
90
|
+
@click.argument("match_id")
|
|
91
|
+
@click.option("--game", "-g", default=None, type=int, help="Game number (1-5). Downloads all if omitted.")
|
|
92
|
+
@click.option("--output", "-o", default=None, help="Output file path (default: <matchId>_game_<N>.replay26)")
|
|
93
|
+
def replay(match_id: str, game: int | None, output: str | None):
|
|
94
|
+
"""Download replay file(s) for a match.
|
|
95
|
+
|
|
96
|
+
Downloads .replay26 files locally for offline viewing with `fcode watch`.
|
|
97
|
+
"""
|
|
98
|
+
import urllib.request
|
|
99
|
+
from fcode.api import api_get
|
|
100
|
+
|
|
101
|
+
# Get match games to know how many there are
|
|
102
|
+
data = api_get(f"/api/matches/{match_id}")
|
|
103
|
+
games = data.get("games", [])
|
|
104
|
+
|
|
105
|
+
if not games:
|
|
106
|
+
console.print("[red]No games found for this match (match may still be running).[/red]")
|
|
107
|
+
raise SystemExit(1)
|
|
108
|
+
|
|
109
|
+
if game is not None:
|
|
110
|
+
targets = [g for g in games if g.get("gameNumber") == game]
|
|
111
|
+
if not targets:
|
|
112
|
+
console.print(f"[red]Game {game} not found. Match has games 1-{len(games)}.[/red]")
|
|
113
|
+
raise SystemExit(1)
|
|
114
|
+
else:
|
|
115
|
+
targets = games
|
|
116
|
+
|
|
117
|
+
for g in targets:
|
|
118
|
+
gn = g.get("gameNumber", 0)
|
|
119
|
+
replay_data = api_get("/api/matches/replay", {"matchId": match_id, "game": str(gn)})
|
|
120
|
+
url = replay_data.get("url")
|
|
121
|
+
if not url:
|
|
122
|
+
console.print(f" [yellow]Game {gn}: no replay available[/yellow]")
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
if output and len(targets) == 1:
|
|
126
|
+
out_path = output
|
|
127
|
+
else:
|
|
128
|
+
out_path = f"{match_id}_game_{gn}.replay26"
|
|
129
|
+
|
|
130
|
+
urllib.request.urlretrieve(url, out_path)
|
|
131
|
+
console.print(f" [green]Game {gn}:[/green] {out_path}")
|
|
132
|
+
|
|
133
|
+
console.print(f"\n[dim]View with: fcode watch <file>[/dim]")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# --- watch ---
|
|
137
|
+
|
|
138
|
+
@match.command("watch")
|
|
139
|
+
@click.argument("match_id")
|
|
140
|
+
@click.option("--game", "-g", default=None, type=int, help="Game number within match")
|
|
141
|
+
def watch(match_id: str, game: int | None):
|
|
142
|
+
"""Open a match replay in the visualizer (browser).
|
|
143
|
+
|
|
144
|
+
Shortcut for `fcode watch --match <id>`.
|
|
145
|
+
"""
|
|
146
|
+
from fcode.auth import get_api_url
|
|
147
|
+
import webbrowser
|
|
148
|
+
|
|
149
|
+
url = f"{get_api_url()}/visualiser?matchId={match_id}"
|
|
150
|
+
if game is not None:
|
|
151
|
+
url += f"&game={game}"
|
|
152
|
+
webbrowser.open(url)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# --- tests ---
|
|
156
|
+
from fcode.commands.test_matches import _show_test_matches
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@match.command("tests")
|
|
160
|
+
@click.option("--limit", default=20, type=int, help="Number of test runs to show")
|
|
161
|
+
def tests(limit: int):
|
|
162
|
+
"""List your remote test run matches."""
|
|
163
|
+
_show_test_matches(limit)
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""fcode matches — list and filter matches."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.table import Table
|
|
6
|
+
|
|
7
|
+
from fcode.api import api_get
|
|
8
|
+
from fcode.auth import load_credentials
|
|
9
|
+
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _show_matches(match_type: str | None, team: str | None, limit: int, cursor: str | None, mine: bool = False):
|
|
14
|
+
"""Core implementation for listing matches."""
|
|
15
|
+
params: dict[str, str | list[str]] = {"limit": str(min(limit, 100))}
|
|
16
|
+
if match_type:
|
|
17
|
+
params["type"] = match_type
|
|
18
|
+
if mine and not team:
|
|
19
|
+
creds = load_credentials()
|
|
20
|
+
if creds and creds.get("team"):
|
|
21
|
+
params["teamIds"] = creds["team"]["id"]
|
|
22
|
+
else:
|
|
23
|
+
console.print("[red]Not on a team. Use --team instead.[/red]")
|
|
24
|
+
raise SystemExit(1)
|
|
25
|
+
elif team:
|
|
26
|
+
# Try to resolve team name to ID via search
|
|
27
|
+
search_data = api_get("/api/teams/search", {"q": team})
|
|
28
|
+
search_results = search_data.get("teams", [])
|
|
29
|
+
# Check for exact match first, then fall back to first result
|
|
30
|
+
exact = [t for t in search_results if t["teamName"].lower() == team.lower()]
|
|
31
|
+
if exact:
|
|
32
|
+
params["teamIds"] = exact[0]["teamId"]
|
|
33
|
+
elif search_results:
|
|
34
|
+
params["teamIds"] = search_results[0]["teamId"]
|
|
35
|
+
else:
|
|
36
|
+
# Maybe it's already a team ID
|
|
37
|
+
params["teamIds"] = team
|
|
38
|
+
if cursor:
|
|
39
|
+
params["cursor"] = cursor
|
|
40
|
+
|
|
41
|
+
data = api_get("/api/matches", params)
|
|
42
|
+
match_list = data.get("matches", [])
|
|
43
|
+
next_cursor = data.get("nextCursor")
|
|
44
|
+
|
|
45
|
+
if not match_list:
|
|
46
|
+
label = match_type or "all"
|
|
47
|
+
console.print(f"[dim]No {label} matches found.[/dim]")
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
creds = load_credentials()
|
|
51
|
+
my_team_id = None
|
|
52
|
+
if creds and creds.get("team"):
|
|
53
|
+
my_team_id = creds["team"].get("id")
|
|
54
|
+
|
|
55
|
+
show_type = match_type is None
|
|
56
|
+
|
|
57
|
+
table = Table(show_header=True, header_style="bold")
|
|
58
|
+
table.add_column("Match ID", style="dim")
|
|
59
|
+
if show_type:
|
|
60
|
+
table.add_column("Type", style="dim")
|
|
61
|
+
table.add_column("Team A")
|
|
62
|
+
table.add_column("Score", justify="center")
|
|
63
|
+
table.add_column("Team B")
|
|
64
|
+
table.add_column("Status")
|
|
65
|
+
table.add_column("Date", style="dim")
|
|
66
|
+
|
|
67
|
+
for m in match_list:
|
|
68
|
+
team_a_name = m.get("teamAName", "?")
|
|
69
|
+
team_b_name = m.get("teamBName", "?")
|
|
70
|
+
team_a_id = m.get("teamAId", "")
|
|
71
|
+
team_b_id = m.get("teamBId", "")
|
|
72
|
+
|
|
73
|
+
if m.get("sourceMatchAId"):
|
|
74
|
+
team_a_name += " [dim](OLD)[/dim]"
|
|
75
|
+
if m.get("sourceMatchBId"):
|
|
76
|
+
team_b_name += " [dim](OLD)[/dim]"
|
|
77
|
+
|
|
78
|
+
if my_team_id and team_a_id == my_team_id:
|
|
79
|
+
team_a_name = f"[bold]{team_a_name}[/bold]"
|
|
80
|
+
if my_team_id and team_b_id == my_team_id:
|
|
81
|
+
team_b_name = f"[bold]{team_b_name}[/bold]"
|
|
82
|
+
|
|
83
|
+
winner_id = m.get("winnerId")
|
|
84
|
+
if winner_id == team_a_id:
|
|
85
|
+
team_a_name = f"[green]{team_a_name}[/green]"
|
|
86
|
+
team_b_name = f"[red]{team_b_name}[/red]"
|
|
87
|
+
elif winner_id == team_b_id:
|
|
88
|
+
team_a_name = f"[red]{team_a_name}[/red]"
|
|
89
|
+
team_b_name = f"[green]{team_b_name}[/green]"
|
|
90
|
+
|
|
91
|
+
score = f"{m.get('scoreA', 0)}-{m.get('scoreB', 0)}"
|
|
92
|
+
status = m.get("status", "?")
|
|
93
|
+
match_id = m.get("id", "?")
|
|
94
|
+
|
|
95
|
+
date = ""
|
|
96
|
+
raw_date = m.get("completedAt") or m.get("createdAt")
|
|
97
|
+
if raw_date:
|
|
98
|
+
date = raw_date[:16].replace("T", " ")
|
|
99
|
+
|
|
100
|
+
row: list[str] = [match_id]
|
|
101
|
+
if show_type:
|
|
102
|
+
triggered = m.get("triggeredBy", "?")
|
|
103
|
+
row.append("UR" if triggered == "unrated" else triggered)
|
|
104
|
+
row.extend([team_a_name, score, team_b_name, status, date])
|
|
105
|
+
table.add_row(*row)
|
|
106
|
+
|
|
107
|
+
console.print(table)
|
|
108
|
+
|
|
109
|
+
if next_cursor:
|
|
110
|
+
console.print(f"\n[dim]More results available. Use --cursor '{next_cursor}'[/dim]")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@click.command()
|
|
114
|
+
@click.option(
|
|
115
|
+
"--type",
|
|
116
|
+
"match_type",
|
|
117
|
+
default=None,
|
|
118
|
+
type=click.Choice(["ladder", "unrated"]),
|
|
119
|
+
help="Filter by match type (shows all if omitted)",
|
|
120
|
+
)
|
|
121
|
+
@click.option("--team", default=None, help="Filter by team name or team ID")
|
|
122
|
+
@click.option("--mine", is_flag=True, help="Show only your team's matches")
|
|
123
|
+
@click.option("--limit", default=20, type=int, help="Number of matches to show (max 100)")
|
|
124
|
+
@click.option("--cursor", default=None, help="Pagination cursor (from previous page)")
|
|
125
|
+
def matches(match_type: str | None, team: str | None, mine: bool, limit: int, cursor: str | None):
|
|
126
|
+
"""List recent matches. Use --type to filter by ladder or unrated."""
|
|
127
|
+
_show_matches(match_type, team, limit, cursor, mine=mine)
|
fcode/commands/run.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
|
|
7
|
+
from fcode.config import find_config
|
|
8
|
+
|
|
9
|
+
console = Console()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _print_summary(console: Console, result: dict, name_a: str, name_b: str):
|
|
13
|
+
winner = result["winner"]
|
|
14
|
+
turns = result["turns"]
|
|
15
|
+
condition = result["win_condition"]
|
|
16
|
+
resign_message = result.get("resign_message")
|
|
17
|
+
|
|
18
|
+
condition_labels = {
|
|
19
|
+
"core_destroyed": "Core destroyed",
|
|
20
|
+
"resigned": "Resigned",
|
|
21
|
+
"resources": "Resources (tiebreak)",
|
|
22
|
+
"titanium_collected": "Titanium collected (tiebreak)",
|
|
23
|
+
"harvesters": "Harvesters (tiebreak)",
|
|
24
|
+
"titanium_stored": "Titanium stored (tiebreak)",
|
|
25
|
+
"coinflip": "Coinflip (tiebreak)",
|
|
26
|
+
"timeout": "Draw",
|
|
27
|
+
}
|
|
28
|
+
reason = condition_labels.get(condition, condition)
|
|
29
|
+
|
|
30
|
+
if winner == "A":
|
|
31
|
+
winner_name = name_a
|
|
32
|
+
winner_style = "[bold green]"
|
|
33
|
+
elif winner == "B":
|
|
34
|
+
winner_name = name_b
|
|
35
|
+
winner_style = "[bold green]"
|
|
36
|
+
else:
|
|
37
|
+
winner_name = "Draw"
|
|
38
|
+
winner_style = "[bold yellow]"
|
|
39
|
+
|
|
40
|
+
console.print()
|
|
41
|
+
console.print(f" {winner_style}Winner: {winner_name}[/] [dim]({reason}, turn {turns})[/dim]")
|
|
42
|
+
if resign_message:
|
|
43
|
+
console.print(f" [dim italic]Resign message: {resign_message}[/dim italic]")
|
|
44
|
+
console.print()
|
|
45
|
+
|
|
46
|
+
# Resource table
|
|
47
|
+
from rich.table import Table
|
|
48
|
+
t = Table(show_header=True, box=None, padding=(0, 2))
|
|
49
|
+
t.add_column("", style="dim")
|
|
50
|
+
a_style = "bold" if winner == "A" else ""
|
|
51
|
+
b_style = "bold" if winner == "B" else ""
|
|
52
|
+
t.add_column(name_a, justify="right", style=a_style)
|
|
53
|
+
t.add_column(name_b, justify="right", style=b_style)
|
|
54
|
+
t.add_row(
|
|
55
|
+
"Titanium",
|
|
56
|
+
f"{result['a_titanium']} ({result['a_titanium_collected']} mined)",
|
|
57
|
+
f"{result['b_titanium']} ({result['b_titanium_collected']} mined)",
|
|
58
|
+
)
|
|
59
|
+
t.add_row("Units", str(result["a_units"]), str(result["b_units"]))
|
|
60
|
+
t.add_row("Buildings", str(result["a_buildings"]), str(result["b_buildings"]))
|
|
61
|
+
console.print(t)
|
|
62
|
+
console.print()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def resolve_map_path(path_str: str, maps_dir: Path) -> Path:
|
|
66
|
+
"""Resolve a map path. Tries: raw path, maps_dir/path, path+.map26, maps_dir/path+.map26."""
|
|
67
|
+
p = Path(path_str)
|
|
68
|
+
if p.exists():
|
|
69
|
+
return p
|
|
70
|
+
|
|
71
|
+
if not p.is_absolute():
|
|
72
|
+
# Try maps_dir/path
|
|
73
|
+
candidate = maps_dir / path_str
|
|
74
|
+
if candidate.exists():
|
|
75
|
+
return candidate
|
|
76
|
+
# Try adding .map26 extension
|
|
77
|
+
if not path_str.endswith(".map26"):
|
|
78
|
+
with_ext = Path(path_str + ".map26")
|
|
79
|
+
if with_ext.exists():
|
|
80
|
+
return with_ext
|
|
81
|
+
candidate_ext = maps_dir / (path_str + ".map26")
|
|
82
|
+
if candidate_ext.exists():
|
|
83
|
+
return candidate_ext
|
|
84
|
+
|
|
85
|
+
return p # Return as-is; caller will get a file-not-found naturally
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def resolve_bot_path(path_str: str, bots_dir: Path) -> str:
|
|
89
|
+
"""Resolve a bot path. Checks raw path first, then bots_dir/path."""
|
|
90
|
+
p = Path(path_str)
|
|
91
|
+
|
|
92
|
+
# If it's already a valid path, use it directly
|
|
93
|
+
if not p.exists() and not p.is_absolute():
|
|
94
|
+
# Try resolving relative to bots_dir
|
|
95
|
+
candidate = bots_dir / path_str
|
|
96
|
+
if candidate.exists():
|
|
97
|
+
p = candidate
|
|
98
|
+
|
|
99
|
+
if p.is_dir():
|
|
100
|
+
main_py = p / "main.py"
|
|
101
|
+
if main_py.is_file():
|
|
102
|
+
return str(main_py.resolve())
|
|
103
|
+
raise click.BadParameter(
|
|
104
|
+
f"Directory '{path_str}' does not contain main.py"
|
|
105
|
+
)
|
|
106
|
+
if p.is_file():
|
|
107
|
+
return str(p.resolve())
|
|
108
|
+
raise click.BadParameter(f"Bot not found: '{path_str}'")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@click.command()
|
|
112
|
+
@click.argument("bot_a")
|
|
113
|
+
@click.argument("bot_b")
|
|
114
|
+
@click.argument("map_path", required=False, default=None)
|
|
115
|
+
@click.option("--replay", default=None, help="Output replay path")
|
|
116
|
+
@click.option("--seed", default=None, type=int)
|
|
117
|
+
@click.option("--watch", "auto_watch", is_flag=True, help="Open visualizer after match")
|
|
118
|
+
@click.option("--tle", default=0, type=int, help="Turn time limit in ms (0 to disable, server uses 10)")
|
|
119
|
+
@click.option("--map-random", is_flag=True, help="Pick a random map from maps directory")
|
|
120
|
+
def run(bot_a: str, bot_b: str, map_path: str | None, replay: str | None, seed: int | None, auto_watch: bool, tle: int, map_random: bool):
|
|
121
|
+
"""Run a local match between two bots.
|
|
122
|
+
|
|
123
|
+
MAP_PATH is optional — if omitted, uses the first .map26 file in the maps directory.
|
|
124
|
+
"""
|
|
125
|
+
from fcode.fcode_engine import run_game
|
|
126
|
+
|
|
127
|
+
config, project_root = find_config()
|
|
128
|
+
|
|
129
|
+
# Apply config defaults where CLI didn't override
|
|
130
|
+
replay = replay or config.replay
|
|
131
|
+
seed = seed if seed is not None else config.seed
|
|
132
|
+
bots_dir = (project_root / config.bots_dir).resolve()
|
|
133
|
+
maps_dir = (project_root / config.maps_dir).resolve()
|
|
134
|
+
|
|
135
|
+
# Game types are re-exported in fcode/__init__.py via _types.py,
|
|
136
|
+
# so `from fcode import *` already works. engine_root is passed to the
|
|
137
|
+
# Rust engine for sys.path setup (redundant here, needed by server binary).
|
|
138
|
+
engine_root = str(Path(__file__).resolve().parent.parent)
|
|
139
|
+
|
|
140
|
+
player_a = resolve_bot_path(bot_a, bots_dir)
|
|
141
|
+
player_b = resolve_bot_path(bot_b, bots_dir)
|
|
142
|
+
|
|
143
|
+
# Resolve map — pick first (or random) if not specified
|
|
144
|
+
if map_path is None:
|
|
145
|
+
maps = sorted(maps_dir.glob("*.map26"))
|
|
146
|
+
if not maps:
|
|
147
|
+
console.print("[red]No .map26 files found in maps/ directory. Provide a map path.[/red]")
|
|
148
|
+
raise SystemExit(1)
|
|
149
|
+
if map_random:
|
|
150
|
+
import random
|
|
151
|
+
mp = random.choice(maps)
|
|
152
|
+
else:
|
|
153
|
+
mp = maps[0]
|
|
154
|
+
else:
|
|
155
|
+
mp = resolve_map_path(map_path, maps_dir)
|
|
156
|
+
resolved_map = str(mp.resolve())
|
|
157
|
+
|
|
158
|
+
name_a = Path(player_a).parent.name if Path(player_a).name == "main.py" else Path(player_a).stem
|
|
159
|
+
name_b = Path(player_b).parent.name if Path(player_b).name == "main.py" else Path(player_b).stem
|
|
160
|
+
|
|
161
|
+
console.print(f"[bold]Running match:[/bold] {name_a} vs {name_b}")
|
|
162
|
+
tle_label = "off" if tle == 0 else f"{tle}ms"
|
|
163
|
+
console.print(f" Map: {mp.name} Seed: {seed} Replay: {replay} TLE: {tle_label}")
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
result = run_game(player_a, player_b, engine_root, resolved_map, replay, seed, tle)
|
|
167
|
+
except Exception as e:
|
|
168
|
+
console.print(f"[red bold]Error:[/red bold] {e}")
|
|
169
|
+
raise SystemExit(1)
|
|
170
|
+
|
|
171
|
+
if os.path.exists(replay):
|
|
172
|
+
console.print(f"[green]Replay written to {replay}[/green]")
|
|
173
|
+
|
|
174
|
+
_print_summary(console, result, name_a, name_b)
|
|
175
|
+
|
|
176
|
+
if auto_watch:
|
|
177
|
+
from fcode.commands import watch as watch_mod
|
|
178
|
+
ctx = click.get_current_context()
|
|
179
|
+
ctx.invoke(watch_mod.watch, replay_file=replay)
|
|
180
|
+
|
|
181
|
+
# Skip normal Py_Finalize to avoid CPython 3.12 bug where
|
|
182
|
+
# Py_EndInterpreter doesn't fully unregister sub-interpreters,
|
|
183
|
+
# causing a fatal "remaining subinterpreters" abort on exit.
|
|
184
|
+
os._exit(0)
|
|
185
|
+
|
|
186
|
+
# do not put code after this
|