Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/ingest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ jobs:
GH_TOKEN: ${{ github.token }}
run: |
mkdir -p .inbox
gh issue list --repo "$GITHUB_REPOSITORY" --label result-submission --state open --limit 100 --json number,author,body --jq '.[] | @base64' > .inbox/issues
gh api --paginate "repos/$GITHUB_REPOSITORY/issues?labels=result-submission&state=open&per_page=100" --jq '.[] | select(.pull_request == null) | {number,author,body} | @base64' > .inbox/issues
while read -r encoded; do
[ -z "$encoded" ] && continue
printf '%s' "$encoded" | base64 --decode > .inbox/issue.json
Expand All @@ -53,6 +53,7 @@ jobs:
python scripts/aggregate.py --root .
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
rm -rf .inbox
for path in results leaderboard matchmaking clients.json site/data; do
[ -e "$path" ] && git add -- "$path"
done
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,6 @@ The submitting issue account and `clientId` must be registered. Every bot must b

## Rules

Do not edit raw facts, generated projections, or the static dashboard data in a pull request. Do not submit replays; retain replay evidence locally. Duplicate battle IDs, engine mismatches, unknown bots, unregistered clients, banned accounts, malformed batches, and implausible score sets are rejected.
Do not edit raw facts, generated projections, or the static dashboard data in a pull request. Do not submit replays; retain replay evidence locally. Duplicate battle IDs, engine mismatches, preset mismatches, unknown bots, unregistered clients, banned accounts, malformed batches, and inconsistent score components are rejected. Fork-pull-request result submission is not supported in V1.

All contributions are made under Apache-2.0 and must follow the project code of conduct and governance process.
2 changes: 1 addition & 1 deletion GOVERNANCE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Governance and operations

The `robocode-dev` organization owns this repository. Moderators review client registrations, catalog updates, bans, and exclusions through ordinary pull requests. CI is the only writer of accepted raw facts and generated projections on `main`.
The `robocode-dev` organization owns this repository. Moderators review client registrations, catalog updates, bans, exclusions, and all ordinary code or policy changes through pull requests with the Verify Rumble data workflow green. CI is the only writer of accepted raw facts and generated projections on `main`. GitHub cannot distinguish the built-in Actions writer from human collaborators in a repository ruleset without a secret-bearing organization app, so this boundary is a documented governance convention for V1.

## Moderation

Expand Down
57 changes: 43 additions & 14 deletions scripts/compact.py
Original file line number Diff line number Diff line change
@@ -1,39 +1,68 @@
#!/usr/bin/env python3
"""Pack aged raw facts into equivalent monthly rollups for archival transfer."""
"""Move aged raw facts to an archive and retain equivalent verified rollups."""

from __future__ import annotations

import argparse
import shutil
from collections import defaultdict
from pathlib import Path

from aggregate import aggregate
from common import read_json, write_json


def compact(root: Path, *, before: str) -> list[Path]:
"""Write rollups for facts completed before an ISO-8601 date without deleting facts."""
grouped: dict[tuple[str, str], list[dict]] = defaultdict(list)
def projection_snapshot(root: Path) -> dict[str, bytes]:
"""Capture every generated projection for compaction equivalence checking."""
paths = [root / "clients.json"] + [path for directory in ("leaderboard", "matchmaking", "site/data") for path in (root / directory).rglob("*.json") if (root / directory).exists()]
return {path.relative_to(root).as_posix(): path.read_bytes() for path in paths if path.is_file()}


def compact(root: Path, *, before: str, archive_root: Path) -> list[Path]:
"""Archive selected raw facts, write rollups, and roll back if projections differ."""
selected: dict[tuple[str, str], list[Path]] = defaultdict(list)
for path in sorted((root / "results" / "raw").rglob("*.json")) if (root / "results" / "raw").exists() else []:
record = read_json(path)
if str(record.get("completedAt", ""))[:10] < before:
date = str(record["completedAt"])[:7].split("-")
grouped[(date[0], date[1])].append(record)
written: list[Path] = []
for (year, month), records in sorted(grouped.items()):
target = root / "results" / "rollups" / year / f"{month}.json"
write_json(target, {"schemaVersion": 1, "month": f"{year}-{month}", "results": sorted(records, key=lambda item: (str(item.get("completedAt")), str(item.get("payloadHash"))))})
written.append(target)
return written
selected[(date[0], date[1])].append(path)
aggregate(root)
before_snapshot = projection_snapshot(root)
rollups: list[Path] = []
moved: list[tuple[Path, Path]] = []
try:
for (year, month), paths in sorted(selected.items()):
records = [read_json(path) for path in paths]
target = root / "results" / "rollups" / year / f"{month}.json"
write_json(target, {"schemaVersion": 1, "month": f"{year}-{month}", "results": sorted(records, key=lambda item: (str(item.get("completedAt")), str(item.get("payloadHash"))))})
rollups.append(target)
for source in paths:
destination = archive_root / source.relative_to(root)
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(source), str(destination))
moved.append((source, destination))
aggregate(root)
if projection_snapshot(root) != before_snapshot:
raise ValueError("compaction changed a derived projection")
except Exception:
for source, destination in reversed(moved):
source.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(destination), str(source))
for rollup in rollups:
rollup.unlink(missing_ok=True)
aggregate(root)
raise
return rollups


def main() -> int:
"""Run compaction from the command line."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", type=Path, required=True)
parser.add_argument("--before", required=True, help="exclusive ISO date, for example 2026-05-01")
parser.add_argument("--archive-root", type=Path, required=True, help="checkout of the archive branch")
arguments = parser.parse_args()
paths = compact(arguments.root.resolve(), before=arguments.before)
print(f"wrote {len(paths)} rollup(s)")
paths = compact(arguments.root.resolve(), before=arguments.before, archive_root=arguments.archive_root.resolve())
print(f"wrote {len(paths)} verified rollup(s)")
return 0


Expand Down
25 changes: 19 additions & 6 deletions site/app.js
Original file line number Diff line number Diff line change
@@ -1,25 +1,38 @@
const select = document.querySelector('#game-type');
const status = document.querySelector('#status');
const body = document.querySelector('#leaderboard');
let entries = [];
let sortField = 'aps';
let descending = true;

function renderEntries() {
body.replaceChildren();
[...entries].sort((first, second) => descending ? second[sortField] - first[sortField] : first[sortField] - second[sortField]).forEach((entry, index) => {
const row = document.createElement('tr');
row.innerHTML = `<td>${index + 1}</td><td><a href="data/bots/${encodeURIComponent(entry.name)}-${encodeURIComponent(entry.version)}.json">${entry.bot}</a></td><td>${entry.aps.toFixed(2)}</td><td>${entry.battles}</td><td>${entry.pairings}</td>`;
body.append(row);
});
}

async function loadLeaderboard() {
const gameType = select.value;
status.textContent = 'Loading leaderboard…';
body.replaceChildren();
try {
const response = await fetch(`data/leaderboard/${gameType}.json`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
data.entries.forEach((entry, index) => {
const row = document.createElement('tr');
row.innerHTML = `<td>${index + 1}</td><td><a href="data/bots/${encodeURIComponent(entry.name)}-${encodeURIComponent(entry.version)}.json">${entry.bot}</a></td><td>${entry.aps.toFixed(2)}</td><td>${entry.battles}</td><td>${entry.pairings}</td>`;
body.append(row);
});
entries = data.entries;
renderEntries();
status.textContent = `${data.entries.length} active bots · behavior version ${data.behaviorVersion}`;
} catch (error) {
status.textContent = `The leaderboard is unavailable: ${error.message}`;
}
}

select.addEventListener('change', loadLeaderboard);
document.querySelectorAll('[data-sort]').forEach(button => button.addEventListener('click', () => {
if (sortField === button.dataset.sort) descending = !descending;
else { sortField = button.dataset.sort; descending = true; }
renderEntries();
}));
loadLeaderboard();
2 changes: 1 addition & 1 deletion site/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ <h1>Tank Royale Rumble</h1>
<label>Game type <select id="game-type"><option value="1v1">1v1</option><option value="twinduel">TwinDuel</option><option value="melee">Melee</option></select></label>
<p id="status" aria-live="polite">Loading leaderboard…</p>
<table>
<thead><tr><th>Rank</th><th>Bot</th><th>APS</th><th>Battles</th><th>Pairings</th></tr></thead>
<thead><tr><th>Rank</th><th>Bot</th><th><button type="button" data-sort="aps">APS</button></th><th><button type="button" data-sort="battles">Battles</button></th><th><button type="button" data-sort="pairings">Pairings</button></th></tr></thead>
<tbody id="leaderboard"></tbody>
</table>
<p><a href="https://github.com/robocode-dev/rumble-data/blob/main/CONTRIBUTING.md">Contribute ranked battles</a></p>
Expand Down
8 changes: 5 additions & 3 deletions tests/test_rumble_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,9 +155,9 @@ def testRDA003_IntegrationPositive_compaction_preserves_deterministic_projection
ingest(self.root, self.envelope(), account="alice")
aggregate(self.root)
before = (self.root / "leaderboard/1v1.json").read_text(encoding="utf-8")
compact(self.root, before="2026-09-01")
shutil.rmtree(self.root / "results/raw")
aggregate(self.root)
archive = self.root / "archive"
compact(self.root, before="2026-09-01", archive_root=archive)
self.assertTrue((archive / "results/raw/2026/08").exists())
after = (self.root / "leaderboard/1v1.json").read_text(encoding="utf-8")
self.assertEqual(before, after)

Expand All @@ -179,6 +179,8 @@ def testRDA004_E2EPositive_dashboard_references_versioned_projection_and_bot_det
self.assertIn("game-type", page)
self.assertIn("data/leaderboard/${gameType}.json", script)
self.assertIn("data/bots/", script)
self.assertIn("data-sort", page)
self.assertIn("renderEntries", script)


if __name__ == "__main__":
Expand Down
Loading