From a6a6df2b83b0f2204d8e111048d3d0d3db5c506c Mon Sep 17 00:00:00 2001 From: "Flemming N. Larsen" Date: Sun, 2 Aug 2026 21:40:38 +0200 Subject: [PATCH] fix: harden Rumble result ingestion --- .github/workflows/ingest.yml | 7 ++- CONTRIBUTING.md | 4 +- GOVERNANCE.md | 2 +- scripts/compact.py | 57 ++++++++++++++----- scripts/ingest.py | 40 ++++++------- scripts/validate.py | 105 +++++++++++++++++++---------------- site/app.js | 25 +++++++-- site/index.html | 2 +- tests/test_rumble_data.py | 39 +++++++++---- 9 files changed, 176 insertions(+), 105 deletions(-) diff --git a/.github/workflows/ingest.yml b/.github/workflows/ingest.yml index 716d36f..385856c 100644 --- a/.github/workflows/ingest.yml +++ b/.github/workflows/ingest.yml @@ -29,14 +29,14 @@ 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 number=$(jq -r .number .inbox/issue.json) account=$(jq -r .author.login .inbox/issue.json) jq -r .body .inbox/issue.json > .inbox/body.md - if python scripts/extract_envelope.py --body .inbox/body.md --output .inbox/envelope.json && python scripts/ingest.py --root . --account "$account" --input .inbox/envelope.json > .inbox/outcome.txt 2>&1; then + if { python scripts/extract_envelope.py --body .inbox/body.md --output .inbox/envelope.json && python scripts/ingest.py --root . --account "$account" --input .inbox/envelope.json; } > .inbox/outcome.txt 2>&1; then printf 'Accepted result batch:\n\n```text\n' > .inbox/comment.md cat .inbox/outcome.txt >> .inbox/comment.md printf '\n```\n' >> .inbox/comment.md @@ -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' - git add results leaderboard matchmaking clients.json site/data + rm -rf .inbox + git add -A git diff --cached --quiet || git commit -m 'chore: ingest Rumble result batch' git push diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8eb315b..119560e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,12 +6,12 @@ Open a pull request that adds `clients/.json`. Its `account` ## Submit a ranked batch -Create an issue titled `[result] `, apply the `result-submission` label, and place exactly one JSON envelope in a fenced `json` block. The envelope has `schemaVersion: 1`, `clientId`, `clientVersion`, and 1–60 `results`. Each result supplies a UUID `battleId`, `completedAt`, the pinned `behaviorVersion`, a supported game type, and one participant record per required bot with `name`, `version`, and non-negative `totalScore`. +Create an issue titled `[result] `, apply the `result-submission` label, and place exactly one JSON envelope in a fenced `json` block. The envelope has `schemaVersion: 1`, `clientId`, `clientVersion`, and 1–60 whole-battle `results`. Each result supplies a UUID `battleId`, `completedAt`, the pinned `behaviorVersion`, game type, preset rounds and battlefield, and one Battle Runner result per required participant: immutable `name`, `version`, and `isTeam`, rank, total score, six score components, and first/second/third-place counts. The submitting issue account and `clientId` must be registered. Every bot must be active in `catalog.json`; the catalog is synchronized from the reviewed Rumble bot catalog by maintainers. The drain workflow closes every processed issue with accepted and rejected record diagnostics. ## 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. diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 06ffcdf..b76768e 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -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 diff --git a/scripts/compact.py b/scripts/compact.py index f55a201..db0ddb4 100644 --- a/scripts/compact.py +++ b/scripts/compact.py @@ -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 diff --git a/scripts/ingest.py b/scripts/ingest.py index aa08834..6a99df6 100644 --- a/scripts/ingest.py +++ b/scripts/ingest.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Validate a result envelope and append each new record as an immutable raw fact.""" +"""Append independently validated Rumble result records as immutable raw facts.""" from __future__ import annotations @@ -7,11 +7,10 @@ from pathlib import Path from common import read_json, write_json -from validate import ValidationError, validate_envelope +from validate import AcceptedResult, ValidationError, validate_batch def raw_path(root: Path, completed_at: str, digest: str) -> Path: - """Return the deterministic raw-fact path for a result record.""" date = completed_at[:10].split("-") if len(date) != 3 or not all(part.isdigit() for part in date): raise ValidationError("completedAt must start with an ISO-8601 date") @@ -19,45 +18,46 @@ def raw_path(root: Path, completed_at: str, digest: str) -> Path: def existing_battle_ids(root: Path) -> set[str]: - """Return battle IDs already retained as raw facts.""" result: set[str] = set() - for path in (root / "results" / "raw").rglob("*.json") if (root / "results" / "raw").exists() else []: - result.add(str(read_json(path).get("battleId"))) - for path in (root / "results" / "rollups").rglob("*.json") if (root / "results" / "rollups").exists() else []: - result.update(str(record.get("battleId")) for record in read_json(path).get("results", [])) + for relative in ("results/raw", "results/rollups"): + for path in (root / relative).rglob("*.json") if (root / relative).exists() else []: + value = read_json(path) + records = value.get("results", []) if relative.endswith("rollups") else [value] + result.update(str(record.get("battleId")) for record in records) return result -def ingest(root: Path, envelope: object, *, account: str) -> list[str]: - """Append accepted records once and return stable outcome messages.""" - accepted = validate_envelope(root, envelope, account=account) +def ingest(root: Path, envelope: object, *, account: str) -> tuple[list[str], list[str]]: + """Persist valid records while reporting every rejected record.""" + accepted, rejected = validate_batch(root, envelope, account=account) seen = existing_battle_ids(root) - duplicates = [item.record["battleId"] for item in accepted if item.record["battleId"] in seen] - if duplicates: - raise ValidationError(f"duplicate battleId already retained: {', '.join(sorted(duplicates))}") paths: list[str] = [] for item in accepted: + if item.record["battleId"] in seen: + rejected.append(f"battleId {item.record['battleId']}: rejected: duplicate battleId already retained") + continue path = raw_path(root, str(item.record["completedAt"]), item.digest) if path.exists(): - raise ValidationError(f"duplicate payload hash: {item.digest}") + rejected.append(f"battleId {item.record['battleId']}: rejected: duplicate payload hash") + continue write_json(path, item.record) paths.append(path.relative_to(root).as_posix()) - return paths + seen.add(item.record["battleId"]) + return paths, rejected def main() -> int: - """Run result ingestion from the command line.""" parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--root", type=Path, required=True) parser.add_argument("--account", required=True) parser.add_argument("--input", type=Path, required=True) arguments = parser.parse_args() try: - paths = ingest(arguments.root.resolve(), read_json(arguments.input), account=arguments.account) + paths, rejected = ingest(arguments.root.resolve(), read_json(arguments.input), account=arguments.account) except (OSError, ValueError, ValidationError) as error: - print(f"ingestion failed: {error}") + print(f"submission rejected: {error}") return 1 - print("accepted:\n" + "\n".join(paths)) + print("\n".join([f"accepted: {path}" for path in paths] + rejected)) return 0 diff --git a/scripts/validate.py b/scripts/validate.py index 74d75f5..b5b392b 100644 --- a/scripts/validate.py +++ b/scripts/validate.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Validate Tank Royale Rumble result envelopes using only the standard library.""" +"""Validate versioned, whole-battle Rumble result envelopes.""" from __future__ import annotations @@ -13,78 +13,90 @@ from common import content_hash, read_json SCHEMA_VERSION = 1 -GAME_TYPE_PARTICIPANTS = {"1v1": 2, "twinduel": 4, "melee": 10} +SCORE_FIELDS = ("survival", "lastSurvivorBonus", "bulletDamage", "bulletKillBonus", "ramDamage", "ramKillBonus") +PLACE_FIELDS = ("firstPlaces", "secondPlaces", "thirdPlaces") class ValidationError(ValueError): - """A submission error that is safe to show to its contributor.""" + """A submission error that can safely be returned to a contributor.""" @dataclass(frozen=True) class AcceptedResult: - """A validated record with its deterministic content address.""" + """A valid record with its stable content address.""" record: dict[str, Any] digest: str def require(condition: bool, message: str) -> None: - """Raise a diagnostic validation error when condition is false.""" + """Raise a concise validation error when condition is false.""" if not condition: raise ValidationError(message) def registered_client_ids(root: Path, account: str) -> set[str]: - """Return registered client IDs for one forge account.""" registration = root / "clients" / f"{account}.json" require(registration.is_file(), f"account `{account}` is not registered") data = read_json(registration) - require(data.get("account") == account, f"registration for `{account}` has an invalid account field") client_ids = data.get("clientIds") - require(isinstance(client_ids, list) and all(isinstance(item, str) and item for item in client_ids), "registration has invalid clientIds") - return set(client_ids) + require(data.get("account") == account and isinstance(client_ids, list), f"registration for `{account}` is invalid") + return {item for item in client_ids if isinstance(item, str) and item} def active_bots(root: Path) -> dict[tuple[str, str], dict[str, Any]]: - """Return the active catalog entries indexed by immutable bot identity.""" catalog = read_json(root / "catalog.json") - bots = catalog.get("bots") - require(catalog.get("schemaVersion") == SCHEMA_VERSION and isinstance(bots, list), "catalog.json is invalid") - return {(str(bot.get("name")), str(bot.get("version"))): bot for bot in bots if bot.get("status") == "active"} + require(catalog.get("schemaVersion") == SCHEMA_VERSION and isinstance(catalog.get("bots"), list), "catalog.json is invalid") + return {(str(bot.get("name")), str(bot.get("version"))): bot for bot in catalog["bots"] if bot.get("status") == "active"} + + +def validate_header(root: Path, envelope: Any, account: str) -> tuple[list[Any], set[str], dict[tuple[str, str], dict[str, Any]]]: + require(isinstance(envelope, dict), "submission must be a JSON object") + require(envelope.get("schemaVersion") == SCHEMA_VERSION, "unsupported submission schemaVersion") + require(isinstance(envelope.get("clientId"), str) and envelope["clientId"], "submission has no clientId") + require(isinstance(envelope.get("clientVersion"), str) and envelope["clientVersion"], "submission has no clientVersion") + records = envelope.get("results") + require(isinstance(records, list) and records, "submission must contain at least one result") + require(len(records) <= 60, "submission exceeds the 60-result batch limit") + client_ids = registered_client_ids(root, account) + require(envelope["clientId"] in client_ids, f"clientId `{envelope['clientId']}` is not registered to `{account}`") + return records, client_ids, active_bots(root) def validate_result(root: Path, record: Any, *, account: str, client_ids: set[str], known_bots: dict[tuple[str, str], dict[str, Any]]) -> AcceptedResult: - """Validate one result record and return its content-addressed representation.""" require(isinstance(record, dict), "result must be a JSON object") - required = ("battleId", "completedAt", "clientId", "behaviorVersion", "gameType", "participants") + required = ("battleId", "completedAt", "clientId", "behaviorVersion", "gameType", "numberOfRounds", "battlefield", "participants") require(all(field in record for field in required), f"result is missing one of: {', '.join(required)}") try: uuid.UUID(str(record["battleId"])) - except ValueError as error: - raise ValidationError("battleId must be a UUID") from error - try: datetime.fromisoformat(str(record["completedAt"]).replace("Z", "+00:00")) except ValueError as error: - raise ValidationError("completedAt must be an ISO-8601 timestamp") from error + raise ValidationError("battleId or completedAt is invalid") from error require(record["clientId"] in client_ids, f"clientId `{record['clientId']}` is not registered to `{account}`") engine = read_json(root / "engine.json") + game_type = str(record["gameType"]) + preset = engine.get("gameTypes", {}).get(game_type) require(record["behaviorVersion"] == engine.get("behaviorVersion"), "behaviorVersion does not match engine.json") - game_type = record["gameType"] - require(game_type in GAME_TYPE_PARTICIPANTS, f"unsupported gameType `{game_type}`") + require(isinstance(preset, dict), f"unsupported gameType `{game_type}`") + require(record["numberOfRounds"] == preset.get("rounds"), "numberOfRounds does not match the ranked preset") + require(record["battlefield"] == preset.get("battlefield"), "battlefield does not match the ranked preset") participants = record["participants"] - require(isinstance(participants, list) and len(participants) == GAME_TYPE_PARTICIPANTS[game_type], f"{game_type} requires {GAME_TYPE_PARTICIPANTS[game_type]} participants") + require(isinstance(participants, list) and len(participants) == preset.get("participants"), f"{game_type} requires {preset.get('participants')} participants") identities: set[tuple[str, str]] = set() - total_score = 0.0 + ranks: set[int] = set() for participant in participants: require(isinstance(participant, dict), "each participant must be an object") identity = (str(participant.get("name")), str(participant.get("version"))) require(identity in known_bots, f"unknown or inactive bot `{identity[0]} {identity[1]}`") require(identity not in identities, f"bot `{identity[0]} {identity[1]}` appears more than once") identities.add(identity) - score = participant.get("totalScore") - require(isinstance(score, (int, float)) and not isinstance(score, bool) and score >= 0, "totalScore must be a non-negative number") - total_score += float(score) - require(total_score > 0, "at least one participant must have a positive totalScore") + rank = participant.get("rank") + require(isinstance(rank, int) and 1 <= rank <= len(participants) and rank not in ranks, "ranks must be unique ranked positions") + ranks.add(rank) + values = [participant.get(field) for field in SCORE_FIELDS + PLACE_FIELDS] + require(all(isinstance(value, int) and not isinstance(value, bool) and value >= 0 for value in values), "score components and place counts must be non-negative integers") + require(participant.get("totalScore") == sum(participant[field] for field in SCORE_FIELDS), "totalScore must equal the score component sum") + require(all(participant[field] <= record["numberOfRounds"] for field in PLACE_FIELDS), "place count exceeds numberOfRounds") banned = read_json(root / "bans.json") require(account not in set(banned.get("bannedAccounts", [])), f"account `{account}` is banned") disqualified = {(str(item.get("name")), str(item.get("version"))) for item in banned.get("disqualifiedBots", [])} @@ -93,37 +105,36 @@ def validate_result(root: Path, record: Any, *, account: str, client_ids: set[st return AcceptedResult(normalized, content_hash(normalized)) -def validate_envelope(root: Path, envelope: Any, *, account: str) -> list[AcceptedResult]: - """Validate a batch envelope and return every accepted result.""" - require(isinstance(envelope, dict), "submission must be a JSON object") - require(envelope.get("schemaVersion") == SCHEMA_VERSION, "unsupported submission schemaVersion") - client_id = envelope.get("clientId") - require(isinstance(client_id, str) and client_id, "submission has no clientId") - records = envelope.get("results") - require(isinstance(records, list) and records, "submission must contain at least one result") - require(len(records) <= 60, "submission exceeds the 60-result batch limit") - client_ids = registered_client_ids(root, account) - require(client_id in client_ids, f"clientId `{client_id}` is not registered to `{account}`") - known_bots = active_bots(root) - accepted = [validate_result(root, record, account=account, client_ids=client_ids, known_bots=known_bots) for record in records] - battle_ids = [item.record["battleId"] for item in accepted] - require(len(set(battle_ids)) == len(battle_ids), "submission repeats a battleId") - return accepted +def validate_batch(root: Path, envelope: Any, *, account: str) -> tuple[list[AcceptedResult], list[str]]: + """Validate independently, preserving valid records in a mixed batch.""" + records, client_ids, known_bots = validate_header(root, envelope, account) + accepted: list[AcceptedResult] = [] + rejected: list[str] = [] + seen: set[str] = set() + for index, record in enumerate(records, start=1): + try: + item = validate_result(root, record, account=account, client_ids=client_ids, known_bots=known_bots) + if item.record["battleId"] in seen: + raise ValidationError("submission repeats a battleId") + seen.add(item.record["battleId"]) + accepted.append(item) + except ValidationError as error: + rejected.append(f"result {index}: rejected: {error}") + return accepted, rejected def main() -> int: - """Run envelope validation from the command line.""" parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--root", type=Path, required=True) parser.add_argument("--account", required=True) parser.add_argument("--input", type=Path, required=True) arguments = parser.parse_args() try: - results = validate_envelope(arguments.root.resolve(), read_json(arguments.input), account=arguments.account) + accepted, rejected = validate_batch(arguments.root.resolve(), read_json(arguments.input), account=arguments.account) except (OSError, ValueError, ValidationError) as error: - print(f"validation failed: {error}") + print(f"submission rejected: {error}") return 1 - print(f"validated {len(results)} result(s)") + print("\n".join([f"accepted: {item.record['battleId']}" for item in accepted] + rejected)) return 0 diff --git a/site/app.js b/site/app.js index 6bd31c9..316ad1f 100644 --- a/site/app.js +++ b/site/app.js @@ -1,20 +1,28 @@ 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 = `${index + 1}${entry.bot}${entry.aps.toFixed(2)}${entry.battles}${entry.pairings}`; + 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 = `${index + 1}${entry.bot}${entry.aps.toFixed(2)}${entry.battles}${entry.pairings}`; - 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}`; @@ -22,4 +30,9 @@ async function loadLeaderboard() { } 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(); diff --git a/site/index.html b/site/index.html index b54c846..45e3a57 100644 --- a/site/index.html +++ b/site/index.html @@ -13,7 +13,7 @@

Tank Royale Rumble

Loading leaderboard…

- +
RankBotAPSBattlesPairings
RankBot

Contribute ranked battles

diff --git a/tests/test_rumble_data.py b/tests/test_rumble_data.py index 3d6373f..19ebbb5 100644 --- a/tests/test_rumble_data.py +++ b/tests/test_rumble_data.py @@ -25,7 +25,7 @@ def setUp(self) -> None: self.temp = tempfile.TemporaryDirectory() self.root = Path(self.temp.name) (self.root / "clients").mkdir() - self.write("engine.json", {"schemaVersion": 1, "behaviorVersion": 1, "gameTypes": {"1v1": {}}}) + self.write("engine.json", {"schemaVersion": 1, "behaviorVersion": 1, "gameTypes": {"1v1": {"rounds": 35, "battlefield": [800, 600], "participants": 2}}}) self.write("bans.json", {"schemaVersion": 1, "bannedAccounts": [], "disqualifiedBots": []}) self.write("exclusions.json", {"schemaVersion": 1, "battleIds": []}) self.write("clients/alice.json", {"schemaVersion": 1, "account": "alice", "clientIds": ["alice-desktop"]}) @@ -45,13 +45,17 @@ def write(self, relative: str, value: object) -> None: def envelope(self, *, battle_id: str = "d290f1ee-6c54-4b01-90e6-d701748f0851", behavior_version: int = 1) -> dict: return {"schemaVersion": 1, "clientId": "alice-desktop", "clientVersion": "0.1.0", "results": [{ - "battleId": battle_id, "completedAt": "2026-08-02T12:00:00Z", "clientId": "alice-desktop", "behaviorVersion": behavior_version, "gameType": "1v1", - "participants": [{"name": "Alpha", "version": "1.0", "totalScore": 80}, {"name": "Bravo", "version": "1.0", "totalScore": 20}], + "battleId": battle_id, "completedAt": "2026-08-02T12:00:00Z", "clientId": "alice-desktop", "behaviorVersion": behavior_version, "gameType": "1v1", "numberOfRounds": 35, "battlefield": [800, 600], + "participants": [self.participant("Alpha", 1, 80, 35), self.participant("Bravo", 2, 20, 0)], }]} + def participant(self, name: str, rank: int, score: int, first_places: int) -> dict: + return {"name": name, "version": "1.0", "isTeam": False, "rank": rank, "totalScore": score, "survival": score, "lastSurvivorBonus": 0, "bulletDamage": 0, "bulletKillBonus": 0, "ramDamage": 0, "ramKillBonus": 0, "firstPlaces": first_places, "secondPlaces": 0, "thirdPlaces": 0} + def testRDA001_IntegrationPositive_valid_batch_becomes_immutable_fact_and_projections(self) -> None: - paths = ingest(self.root, self.envelope(), account="alice") + paths, rejected = ingest(self.root, self.envelope(), account="alice") self.assertEqual(1, len(paths)) + self.assertEqual([], rejected) self.assertTrue((self.root / paths[0]).is_file()) aggregate(self.root) leaderboard = json.loads((self.root / "leaderboard/1v1.json").read_text(encoding="utf-8")) @@ -60,20 +64,31 @@ def testRDA001_IntegrationPositive_valid_batch_becomes_immutable_fact_and_projec self.assertIn(["Alpha 1.0", "Charlie 1.0"], [pair["bots"] for pair in needed["priorityPairs"]]) def testRDA002_IntegrationNegative_incompatible_or_duplicate_records_leave_no_new_fact(self) -> None: - with self.assertRaisesRegex(ValidationError, "behaviorVersion"): - ingest(self.root, self.envelope(behavior_version=2), account="alice") + paths, rejected = ingest(self.root, self.envelope(behavior_version=2), account="alice") + self.assertEqual([], paths) + self.assertTrue(any("behaviorVersion" in item for item in rejected)) self.assertFalse((self.root / "results/raw").exists()) ingest(self.root, self.envelope(), account="alice") - with self.assertRaisesRegex(ValidationError, "duplicate battleId"): - ingest(self.root, self.envelope(), account="alice") + _, rejected = ingest(self.root, self.envelope(), account="alice") + self.assertTrue(any("duplicate battleId" in item for item in rejected)) + + def testRDA001_IntegrationPositive_mixed_batch_retains_valid_record_and_reports_invalid_record(self) -> None: + envelope = self.envelope() + invalid = self.envelope(battle_id="00000000-0000-0000-0000-000000000001")["results"][0] + invalid["participants"][0]["totalScore"] = 999 + envelope["results"].append(invalid) + paths, rejected = ingest(self.root, envelope, account="alice") + self.assertEqual(1, len(paths)) + self.assertEqual(1, len(rejected)) + self.assertIn("totalScore", rejected[0]) def testRDA003_IntegrationPositive_compaction_preserves_deterministic_projection(self) -> None: 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) @@ -83,6 +98,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__":