Spaces:
Running
Running
Add agent URL hyperlinks, replay downloads, and submit_with_replay endpoint
Browse files- Agent names link to GitHub/project URL when agent_url is provided
- Replay column shows download links for entries with replays
- New submit_with_replay API endpoint accepts replay file uploads
- Serves replay files from submissions/ directory via allowed_paths
- Added agent_url column to CSV schema
- app.py +77 -5
- data/results.csv +2 -2
- tests/test_app.py +113 -0
app.py
CHANGED
|
@@ -45,6 +45,7 @@ DISPLAY_COLUMNS = [
|
|
| 45 |
"Avg Economy",
|
| 46 |
"Avg Game Length",
|
| 47 |
"Date",
|
|
|
|
| 48 |
]
|
| 49 |
|
| 50 |
|
|
@@ -57,9 +58,33 @@ def load_data() -> pd.DataFrame:
|
|
| 57 |
df = df.sort_values("score", ascending=False).reset_index(drop=True)
|
| 58 |
df.insert(0, "Rank", range(1, len(df) + 1))
|
| 59 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
# Rename for display
|
| 61 |
df = df.rename(columns={
|
| 62 |
-
"agent_name": "Agent",
|
| 63 |
"agent_type": "Type",
|
| 64 |
"opponent": "Opponent",
|
| 65 |
"games": "Games",
|
|
@@ -161,7 +186,7 @@ def save_submission(results: dict) -> None:
|
|
| 161 |
fieldnames = [
|
| 162 |
"agent_name", "agent_type", "opponent", "games", "win_rate",
|
| 163 |
"score", "avg_kills", "avg_deaths", "kd_ratio", "avg_economy",
|
| 164 |
-
"avg_game_length", "timestamp", "replay_url",
|
| 165 |
]
|
| 166 |
with open(csv_path, "a", newline="") as f:
|
| 167 |
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
|
@@ -235,6 +260,7 @@ def _score_from_submission(data: dict) -> dict:
|
|
| 235 |
"avg_game_length": data.get("ticks", 0),
|
| 236 |
"timestamp": data.get("timestamp", datetime.now(timezone.utc).strftime("%Y-%m-%d"))[:10],
|
| 237 |
"replay_url": "",
|
|
|
|
| 238 |
}
|
| 239 |
|
| 240 |
|
|
@@ -291,6 +317,39 @@ def handle_api_submit(json_data: str) -> str:
|
|
| 291 |
)
|
| 292 |
|
| 293 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 294 |
# ── UI ────────────────────────────────────────────────────────────────────────
|
| 295 |
|
| 296 |
ABOUT_MD = """
|
|
@@ -441,7 +500,7 @@ def build_app() -> gr.Blocks:
|
|
| 441 |
value=initial_df,
|
| 442 |
datatype=[
|
| 443 |
"number", # Rank
|
| 444 |
-
"
|
| 445 |
"html", # Type (badge)
|
| 446 |
"str", # Opponent
|
| 447 |
"number", # Games
|
|
@@ -453,6 +512,7 @@ def build_app() -> gr.Blocks:
|
|
| 453 |
"number", # Avg Economy
|
| 454 |
"number", # Avg Game Length
|
| 455 |
"str", # Date
|
|
|
|
| 456 |
],
|
| 457 |
interactive=False,
|
| 458 |
show_label=False,
|
|
@@ -498,7 +558,7 @@ def build_app() -> gr.Blocks:
|
|
| 498 |
outputs=[submit_output, leaderboard],
|
| 499 |
)
|
| 500 |
|
| 501 |
-
# API endpoint for CLI auto-upload
|
| 502 |
api_json_input = gr.Textbox(visible=False)
|
| 503 |
api_result = gr.Textbox(visible=False)
|
| 504 |
api_btn = gr.Button(visible=False)
|
|
@@ -509,6 +569,18 @@ def build_app() -> gr.Blocks:
|
|
| 509 |
api_name="submit",
|
| 510 |
)
|
| 511 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 512 |
gr.Markdown(SUBMIT_MD)
|
| 513 |
|
| 514 |
return app
|
|
@@ -516,4 +588,4 @@ def build_app() -> gr.Blocks:
|
|
| 516 |
|
| 517 |
if __name__ == "__main__":
|
| 518 |
app = build_app()
|
| 519 |
-
app.launch()
|
|
|
|
| 45 |
"Avg Economy",
|
| 46 |
"Avg Game Length",
|
| 47 |
"Date",
|
| 48 |
+
"Replay",
|
| 49 |
]
|
| 50 |
|
| 51 |
|
|
|
|
| 58 |
df = df.sort_values("score", ascending=False).reset_index(drop=True)
|
| 59 |
df.insert(0, "Rank", range(1, len(df) + 1))
|
| 60 |
|
| 61 |
+
# Build agent name with optional hyperlink
|
| 62 |
+
if "agent_url" in df.columns:
|
| 63 |
+
df["Agent"] = df.apply(
|
| 64 |
+
lambda r: (
|
| 65 |
+
f'<a href="{r["agent_url"]}" target="_blank">{r["agent_name"]}</a>'
|
| 66 |
+
if pd.notna(r.get("agent_url")) and str(r["agent_url"]).strip()
|
| 67 |
+
else r["agent_name"]
|
| 68 |
+
),
|
| 69 |
+
axis=1,
|
| 70 |
+
)
|
| 71 |
+
else:
|
| 72 |
+
df["Agent"] = df["agent_name"]
|
| 73 |
+
|
| 74 |
+
# Build replay download link
|
| 75 |
+
if "replay_url" in df.columns:
|
| 76 |
+
df["Replay"] = df["replay_url"].apply(
|
| 77 |
+
lambda u: (
|
| 78 |
+
f'<a href="/replays/{u}" download title="Download replay">⬇</a>'
|
| 79 |
+
if pd.notna(u) and str(u).strip()
|
| 80 |
+
else ""
|
| 81 |
+
)
|
| 82 |
+
)
|
| 83 |
+
else:
|
| 84 |
+
df["Replay"] = ""
|
| 85 |
+
|
| 86 |
# Rename for display
|
| 87 |
df = df.rename(columns={
|
|
|
|
| 88 |
"agent_type": "Type",
|
| 89 |
"opponent": "Opponent",
|
| 90 |
"games": "Games",
|
|
|
|
| 186 |
fieldnames = [
|
| 187 |
"agent_name", "agent_type", "opponent", "games", "win_rate",
|
| 188 |
"score", "avg_kills", "avg_deaths", "kd_ratio", "avg_economy",
|
| 189 |
+
"avg_game_length", "timestamp", "replay_url", "agent_url",
|
| 190 |
]
|
| 191 |
with open(csv_path, "a", newline="") as f:
|
| 192 |
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
|
|
|
| 260 |
"avg_game_length": data.get("ticks", 0),
|
| 261 |
"timestamp": data.get("timestamp", datetime.now(timezone.utc).strftime("%Y-%m-%d"))[:10],
|
| 262 |
"replay_url": "",
|
| 263 |
+
"agent_url": data.get("agent_url", ""),
|
| 264 |
}
|
| 265 |
|
| 266 |
|
|
|
|
| 317 |
)
|
| 318 |
|
| 319 |
|
| 320 |
+
def handle_api_submit_with_replay(json_data: str, replay_file) -> str:
|
| 321 |
+
"""API endpoint: accept JSON + replay file. Used by CLI with --replay."""
|
| 322 |
+
try:
|
| 323 |
+
data = json.loads(json_data)
|
| 324 |
+
except (json.JSONDecodeError, Exception) as e:
|
| 325 |
+
return f"Invalid JSON: {e}"
|
| 326 |
+
|
| 327 |
+
is_valid, error = validate_submission(data)
|
| 328 |
+
if not is_valid:
|
| 329 |
+
return f"Validation error: {error}"
|
| 330 |
+
|
| 331 |
+
results_row = _score_from_submission(data)
|
| 332 |
+
|
| 333 |
+
# Save replay if provided
|
| 334 |
+
if replay_file is not None:
|
| 335 |
+
import shutil
|
| 336 |
+
from datetime import datetime, timezone
|
| 337 |
+
|
| 338 |
+
orig = Path(replay_file) if isinstance(replay_file, str) else Path(replay_file.name)
|
| 339 |
+
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
| 340 |
+
slug = data["agent_name"].replace("/", "_").replace(" ", "_")[:30]
|
| 341 |
+
replay_name = f"replay-{slug}-{ts}.orarep"
|
| 342 |
+
shutil.copy2(str(orig), SUBMISSIONS_DIR / replay_name)
|
| 343 |
+
results_row["replay_url"] = replay_name
|
| 344 |
+
|
| 345 |
+
save_submission(results_row)
|
| 346 |
+
|
| 347 |
+
return (
|
| 348 |
+
f"OK: {data['agent_name']} ({data['agent_type']}) "
|
| 349 |
+
f"vs {data['opponent']}: score {results_row['score']}"
|
| 350 |
+
)
|
| 351 |
+
|
| 352 |
+
|
| 353 |
# ── UI ────────────────────────────────────────────────────────────────────────
|
| 354 |
|
| 355 |
ABOUT_MD = """
|
|
|
|
| 500 |
value=initial_df,
|
| 501 |
datatype=[
|
| 502 |
"number", # Rank
|
| 503 |
+
"html", # Agent (may contain hyperlink)
|
| 504 |
"html", # Type (badge)
|
| 505 |
"str", # Opponent
|
| 506 |
"number", # Games
|
|
|
|
| 512 |
"number", # Avg Economy
|
| 513 |
"number", # Avg Game Length
|
| 514 |
"str", # Date
|
| 515 |
+
"html", # Replay (download link)
|
| 516 |
],
|
| 517 |
interactive=False,
|
| 518 |
show_label=False,
|
|
|
|
| 558 |
outputs=[submit_output, leaderboard],
|
| 559 |
)
|
| 560 |
|
| 561 |
+
# API endpoint for CLI auto-upload (JSON only)
|
| 562 |
api_json_input = gr.Textbox(visible=False)
|
| 563 |
api_result = gr.Textbox(visible=False)
|
| 564 |
api_btn = gr.Button(visible=False)
|
|
|
|
| 569 |
api_name="submit",
|
| 570 |
)
|
| 571 |
|
| 572 |
+
# API endpoint for CLI upload with replay
|
| 573 |
+
api_json_input2 = gr.Textbox(visible=False)
|
| 574 |
+
api_replay_input = gr.File(visible=False)
|
| 575 |
+
api_result2 = gr.Textbox(visible=False)
|
| 576 |
+
api_btn2 = gr.Button(visible=False)
|
| 577 |
+
api_btn2.click(
|
| 578 |
+
fn=handle_api_submit_with_replay,
|
| 579 |
+
inputs=[api_json_input2, api_replay_input],
|
| 580 |
+
outputs=[api_result2],
|
| 581 |
+
api_name="submit_with_replay",
|
| 582 |
+
)
|
| 583 |
+
|
| 584 |
gr.Markdown(SUBMIT_MD)
|
| 585 |
|
| 586 |
return app
|
|
|
|
| 588 |
|
| 589 |
if __name__ == "__main__":
|
| 590 |
app = build_app()
|
| 591 |
+
app.launch(allowed_paths=[str(SUBMISSIONS_DIR)])
|
data/results.csv
CHANGED
|
@@ -1,2 +1,2 @@
|
|
| 1 |
-
agent_name,agent_type,opponent,games,win_rate,score,avg_kills,avg_deaths,kd_ratio,avg_economy,avg_game_length,timestamp,replay_url
|
| 2 |
-
qwen/qwen3-coder-next,LLM,Beginner,1,0.0,18.3,1000,2900,0.34,9050,27349,2026-02-25,
|
|
|
|
| 1 |
+
agent_name,agent_type,opponent,games,win_rate,score,avg_kills,avg_deaths,kd_ratio,avg_economy,avg_game_length,timestamp,replay_url,agent_url
|
| 2 |
+
qwen/qwen3-coder-next,LLM,Beginner,1,0.0,18.3,1000,2900,0.34,9050,27349,2026-02-25,,
|
tests/test_app.py
CHANGED
|
@@ -17,6 +17,7 @@ from app import (
|
|
| 17 |
build_app,
|
| 18 |
filter_leaderboard,
|
| 19 |
handle_api_submit,
|
|
|
|
| 20 |
load_data,
|
| 21 |
validate_submission,
|
| 22 |
)
|
|
@@ -195,3 +196,115 @@ class TestApiSubmit:
|
|
| 195 |
import json
|
| 196 |
result = handle_api_submit(json.dumps({"agent_name": "Bot"}))
|
| 197 |
assert "Validation error" in result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
build_app,
|
| 18 |
filter_leaderboard,
|
| 19 |
handle_api_submit,
|
| 20 |
+
handle_api_submit_with_replay,
|
| 21 |
load_data,
|
| 22 |
validate_submission,
|
| 23 |
)
|
|
|
|
| 196 |
import json
|
| 197 |
result = handle_api_submit(json.dumps({"agent_name": "Bot"}))
|
| 198 |
assert "Validation error" in result
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
class TestDisplayColumns:
|
| 202 |
+
"""Test display column configuration."""
|
| 203 |
+
|
| 204 |
+
def test_replay_in_display_columns(self):
|
| 205 |
+
assert "Replay" in DISPLAY_COLUMNS
|
| 206 |
+
|
| 207 |
+
def test_display_columns_count(self):
|
| 208 |
+
assert len(DISPLAY_COLUMNS) == 14
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
class TestAgentUrl:
|
| 212 |
+
"""Test agent URL hyperlink rendering."""
|
| 213 |
+
|
| 214 |
+
def test_agent_url_in_submission(self):
|
| 215 |
+
import json
|
| 216 |
+
import tempfile
|
| 217 |
+
data = {
|
| 218 |
+
"agent_name": "DeathBot",
|
| 219 |
+
"agent_type": "RL",
|
| 220 |
+
"agent_url": "https://github.com/user/deathbot",
|
| 221 |
+
"opponent": "Normal",
|
| 222 |
+
"result": "win",
|
| 223 |
+
"ticks": 5000,
|
| 224 |
+
"kills_cost": 3000,
|
| 225 |
+
"deaths_cost": 1000,
|
| 226 |
+
"assets_value": 8000,
|
| 227 |
+
}
|
| 228 |
+
with tempfile.NamedTemporaryFile(suffix=".csv", delete=False) as f:
|
| 229 |
+
temp_path = Path(f.name)
|
| 230 |
+
with patch("app.DATA_PATH", temp_path):
|
| 231 |
+
result = handle_api_submit(json.dumps(data))
|
| 232 |
+
assert "OK" in result
|
| 233 |
+
assert "DeathBot" in result
|
| 234 |
+
temp_path.unlink(missing_ok=True)
|
| 235 |
+
|
| 236 |
+
def test_agent_url_renders_link(self):
|
| 237 |
+
"""When agent_url is set, Agent column should be a hyperlink."""
|
| 238 |
+
import tempfile
|
| 239 |
+
csv_content = (
|
| 240 |
+
"agent_name,agent_type,opponent,games,win_rate,score,"
|
| 241 |
+
"avg_kills,avg_deaths,kd_ratio,avg_economy,avg_game_length,"
|
| 242 |
+
"timestamp,replay_url,agent_url\n"
|
| 243 |
+
"DeathBot,RL,Normal,10,50.0,60.0,"
|
| 244 |
+
"2000,1500,1.33,9000,15000,"
|
| 245 |
+
"2026-02-26,,https://github.com/user/deathbot\n"
|
| 246 |
+
)
|
| 247 |
+
with tempfile.NamedTemporaryFile(
|
| 248 |
+
mode="w", suffix=".csv", delete=False
|
| 249 |
+
) as f:
|
| 250 |
+
f.write(csv_content)
|
| 251 |
+
temp_path = Path(f.name)
|
| 252 |
+
with patch("app.DATA_PATH", temp_path):
|
| 253 |
+
df = load_data()
|
| 254 |
+
assert '<a href="https://github.com/user/deathbot"' in df["Agent"].iloc[0]
|
| 255 |
+
temp_path.unlink(missing_ok=True)
|
| 256 |
+
|
| 257 |
+
def test_no_url_renders_plain_name(self):
|
| 258 |
+
"""When agent_url is empty, Agent column is plain text."""
|
| 259 |
+
import tempfile
|
| 260 |
+
csv_content = (
|
| 261 |
+
"agent_name,agent_type,opponent,games,win_rate,score,"
|
| 262 |
+
"avg_kills,avg_deaths,kd_ratio,avg_economy,avg_game_length,"
|
| 263 |
+
"timestamp,replay_url,agent_url\n"
|
| 264 |
+
"PlainBot,LLM,Easy,5,20.0,30.0,"
|
| 265 |
+
"1000,2000,0.5,5000,10000,"
|
| 266 |
+
"2026-02-26,,\n"
|
| 267 |
+
)
|
| 268 |
+
with tempfile.NamedTemporaryFile(
|
| 269 |
+
mode="w", suffix=".csv", delete=False
|
| 270 |
+
) as f:
|
| 271 |
+
f.write(csv_content)
|
| 272 |
+
temp_path = Path(f.name)
|
| 273 |
+
with patch("app.DATA_PATH", temp_path):
|
| 274 |
+
df = load_data()
|
| 275 |
+
assert df["Agent"].iloc[0] == "PlainBot"
|
| 276 |
+
temp_path.unlink(missing_ok=True)
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
class TestReplayColumn:
|
| 280 |
+
"""Test replay download link rendering."""
|
| 281 |
+
|
| 282 |
+
def test_replay_link_rendered(self):
|
| 283 |
+
"""Replay column shows download link when replay_url is set."""
|
| 284 |
+
import tempfile
|
| 285 |
+
csv_content = (
|
| 286 |
+
"agent_name,agent_type,opponent,games,win_rate,score,"
|
| 287 |
+
"avg_kills,avg_deaths,kd_ratio,avg_economy,avg_game_length,"
|
| 288 |
+
"timestamp,replay_url,agent_url\n"
|
| 289 |
+
"TestBot,LLM,Easy,1,0.0,18.0,"
|
| 290 |
+
"1000,2000,0.5,5000,10000,"
|
| 291 |
+
"2026-02-26,replay-test-123.orarep,\n"
|
| 292 |
+
)
|
| 293 |
+
with tempfile.NamedTemporaryFile(
|
| 294 |
+
mode="w", suffix=".csv", delete=False
|
| 295 |
+
) as f:
|
| 296 |
+
f.write(csv_content)
|
| 297 |
+
temp_path = Path(f.name)
|
| 298 |
+
with patch("app.DATA_PATH", temp_path):
|
| 299 |
+
df = load_data()
|
| 300 |
+
assert "/replays/replay-test-123.orarep" in df["Replay"].iloc[0]
|
| 301 |
+
assert "download" in df["Replay"].iloc[0]
|
| 302 |
+
temp_path.unlink(missing_ok=True)
|
| 303 |
+
|
| 304 |
+
def test_empty_replay_no_link(self):
|
| 305 |
+
"""Replay column is empty when no replay_url."""
|
| 306 |
+
df = load_data()
|
| 307 |
+
if len(df) > 0:
|
| 308 |
+
# The default test data has no replay
|
| 309 |
+
replay_val = df["Replay"].iloc[0]
|
| 310 |
+
assert replay_val == "" or not str(replay_val).strip()
|