From 33927cb99ed6d346359bcbbf64345b2cde446c9b Mon Sep 17 00:00:00 2001 From: "Flemming N. Larsen" Date: Sun, 2 Aug 2026 22:41:35 +0200 Subject: [PATCH 1/2] fix: align V1 result ingestion contract --- .github/workflows/ingest.yml | 4 +- CONTRIBUTING.md | 6 +- GOVERNANCE.md | 2 +- scripts/aggregate.py | 42 +++++++++- scripts/ingest.py | 56 ++++++++----- scripts/validate.py | 150 ++++++++++++++++++++++++++++------- tests/test_rumble_data.py | 93 +++++++++++++++++++--- 7 files changed, 285 insertions(+), 68 deletions(-) diff --git a/.github/workflows/ingest.yml b/.github/workflows/ingest.yml index 716d36f..375feba 100644 --- a/.github/workflows/ingest.yml +++ b/.github/workflows/ingest.yml @@ -37,11 +37,11 @@ jobs: 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 - printf 'Accepted result batch:\n\n```text\n' > .inbox/comment.md + printf 'Processed result batch:\n\n```text\n' > .inbox/comment.md cat .inbox/outcome.txt >> .inbox/comment.md printf '\n```\n' >> .inbox/comment.md else - printf 'Rejected result batch:\n\n```text\n' > .inbox/comment.md + printf 'Rejected result batch before individual records could be read:\n\n```text\n' > .inbox/comment.md cat .inbox/outcome.txt >> .inbox/comment.md printf '\n```\n' >> .inbox/comment.md fi diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8eb315b..784c940 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,9 +6,11 @@ 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 `results`. Each result supplies a UUID `battleId`, `completedAt`, nested `client.id` and `client.version` matching the envelope, nested `engine.behaviorVersion` matching the engine pin, game type, pinned rounds and arena dimensions, and the complete Battle Runner participant result model. -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. +Each participant supplies cataloged `name` and `version`, `isTeam`, a 1224-system `rank`, `totalScore`, `survival`, `lastSurvivorBonus`, `bulletDamage`, `bulletKillBonus`, `ramDamage`, `ramKillBonus`, `firstPlaces`, `secondPlaces`, and `thirdPlaces`. Scores and place counts are non-negative signed 32-bit integers. `isTeam`, result-entry count, rank placement, and place-count totals must match the selected ranked game type. + +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 validates each record independently, keeps valid records when neighboring records are rejected, and closes every processed issue with a receipt line for every record. ## Rules diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 06ffcdf..98c0803 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -8,7 +8,7 @@ Keep facts immutable. To exclude a disputed result, add its `battleId` to `exclu ## Ingestion -The workflow is triggered by labelled issues and a modest schedule. It serializes runs, validates every issue batch, commits all accepted facts in one commit, regenerates projections, comments on the issue, and closes it. If scheduled workflows are disabled after inactivity, re-enable the workflow; an incoming labelled issue also wakes the system. +The workflow is triggered by labelled issues and a modest schedule. It serializes runs, validates every record in an issue batch independently, commits all accepted facts in one commit, regenerates projections, comments with one receipt line per record, and closes the issue. Aggregation applies the current registrations, bans, disqualified bots, and exclusions to immutable facts, so later moderation immediately changes projections without rewriting history. If scheduled workflows are disabled after inactivity, re-enable the workflow; an incoming labelled issue also wakes the system. ## Compaction and fork drill diff --git a/scripts/aggregate.py b/scripts/aggregate.py index c24da59..1857c6a 100644 --- a/scripts/aggregate.py +++ b/scripts/aggregate.py @@ -14,6 +14,34 @@ TARGET_SAMPLES_PER_PAIRING = 6 +def registered_clients(root: Path) -> dict[str, set[str]]: + """Return current client registrations indexed by forge account.""" + registrations: dict[str, set[str]] = {} + for path in repository_files(root, "clients"): + registration = read_json(path) + account, client_ids = registration.get("account"), registration.get("clientIds") + if isinstance(account, str) and isinstance(client_ids, list) and all(isinstance(client_id, str) and client_id for client_id in client_ids): + registrations[account] = set(client_ids) + return registrations + + +def record_client_id(record: dict[str, Any]) -> str | None: + """Return a record's V1 nested client identifier.""" + client = record.get("client") + return client.get("id") if isinstance(client, dict) and isinstance(client.get("id"), str) else None + + +def eligible_fact(record: dict[str, Any], *, registrations: dict[str, set[str]], banned_accounts: set[str], disqualified_bots: set[tuple[str, str]], exclusions: set[str]) -> bool: + """Select a fact against every current moderation and registration input.""" + account = record.get("submittedBy") + if not isinstance(account, str) or account in banned_accounts or record.get("battleId") in exclusions: + return False + client_id = record_client_id(record) + if client_id not in registrations.get(account, set()): + return False + return not any(identity(participant) in disqualified_bots for participant in record.get("participants", []) if isinstance(participant, dict)) + + def facts(root: Path) -> list[dict[str, Any]]: """Load raw facts and compacted rollups in a deterministic order.""" records: list[dict[str, Any]] = [] @@ -23,7 +51,11 @@ def facts(root: Path) -> list[dict[str, Any]]: rollup = read_json(path) records.extend(rollup.get("results", [])) exclusions = set(read_json(root / "exclusions.json").get("battleIds", [])) - return sorted((record for record in records if record.get("battleId") not in exclusions), key=lambda item: (str(item.get("completedAt")), str(item.get("payloadHash")))) + bans = read_json(root / "bans.json") + banned_accounts = set(bans.get("bannedAccounts", [])) + disqualified_bots = {(str(item.get("name")), str(item.get("version"))) for item in bans.get("disqualifiedBots", [])} + registrations = registered_clients(root) + return sorted((record for record in records if eligible_fact(record, registrations=registrations, banned_accounts=banned_accounts, disqualified_bots=disqualified_bots, exclusions=exclusions)), key=lambda item: (str(item.get("completedAt")), str(item.get("payloadHash")))) def active_catalog(root: Path) -> list[dict[str, Any]]: @@ -39,7 +71,7 @@ def identity(participant: dict[str, Any]) -> tuple[str, str]: def aggregate_game_type(records: list[dict[str, Any]], catalog: list[dict[str, Any]], game_type: str, behavior_version: int) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]: """Produce leaderboard, pairings, and matchmaking advice for one game type.""" eligible = {(str(bot["name"]), str(bot["version"])): bot for bot in catalog} - relevant = [record for record in records if record.get("gameType") == game_type and record.get("behaviorVersion") == behavior_version] + relevant = [record for record in records if record.get("gameType") == game_type and record.get("engine", {}).get("behaviorVersion") == behavior_version] shares: dict[tuple[str, str], dict[tuple[tuple[str, str], ...], list[float]]] = defaultdict(lambda: defaultdict(list)) pairing_counts: dict[tuple[tuple[str, str], ...], int] = defaultdict(int) for record in relevant: @@ -50,7 +82,7 @@ def aggregate_game_type(records: list[dict[str, Any]], catalog: list[dict[str, A for participant in participants: bot = identity(participant) if bot in eligible: - shares[bot][bots].append(float(participant["totalScore"]) / total) + shares[bot][bots].append(float(participant["totalScore"]) / total if total else 0.0) entries = [] for bot, catalog_entry in eligible.items(): bot_pairings = shares.get(bot, {}) @@ -92,7 +124,9 @@ def aggregate(root: Path) -> None: write_json(root / "site" / "data" / "bots" / f"{entry['name']}-{entry['version']}.json", {"schemaVersion": 1, "projectionId": leaderboard["projectionId"], "gameType": game_type, "entry": entry}) client_totals: dict[str, int] = defaultdict(int) for record in records: - client_totals[str(record.get("clientId"))] += 1 + client_id = record_client_id(record) + if client_id is not None: + client_totals[client_id] += 1 write_json(root / "clients.json", {"schemaVersion": 1, "clients": [{"clientId": client_id, "battles": battles} for client_id, battles in sorted(client_totals.items())]}) write_json(root / "site" / "data" / "clients.json", {"schemaVersion": 1, "clients": [{"clientId": client_id, "battles": battles} for client_id, battles in sorted(client_totals.items())]}) diff --git a/scripts/ingest.py b/scripts/ingest.py index aa08834..bb601b8 100644 --- a/scripts/ingest.py +++ b/scripts/ingest.py @@ -7,7 +7,7 @@ from pathlib import Path from common import read_json, write_json -from validate import ValidationError, validate_envelope +from validate import AcceptedResult, ValidationError, ValidationOutcome, validate_envelope def raw_path(root: Path, completed_at: str, digest: str) -> Path: @@ -18,31 +18,49 @@ def raw_path(root: Path, completed_at: str, digest: str) -> Path: return root / "results" / "raw" / date[0] / date[1] / f"{digest}.json" -def existing_battle_ids(root: Path) -> set[str]: - """Return battle IDs already retained as raw facts.""" +def retained_values(root: Path, field: str) -> set[str]: + """Return one field's values from every retained raw fact and rollup record.""" 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"))) + result.add(str(read_json(path).get(field))) 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", [])) + result.update(str(record.get(field)) for record in read_json(path).get("results", [])) 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) - 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: +def format_outcome(outcome: ValidationOutcome) -> str: + """Render one stable receipt line for a submitted record.""" + label = outcome.battle_id or f"result[{outcome.index}]" + return f"{label}: {'accepted' if outcome.accepted else f'rejected: {outcome.error}'}" + + +def ingest(root: Path, envelope: object, *, account: str) -> list[ValidationOutcome]: + """Append every independently accepted record and return all receipt outcomes.""" + outcomes = validate_envelope(root, envelope, account=account) + battle_ids = retained_values(root, "battleId") + payload_hashes = retained_values(root, "payloadHash") + persisted: list[ValidationOutcome] = [] + for outcome in outcomes: + if outcome.accepted is None: + persisted.append(outcome) + continue + item: AcceptedResult = outcome.accepted + battle_id = str(item.record["battleId"]) + if battle_id in battle_ids: + persisted.append(ValidationOutcome(index=outcome.index, battle_id=battle_id, error="duplicate battleId already retained")) + continue + if item.record["payloadHash"] in payload_hashes: + persisted.append(ValidationOutcome(index=outcome.index, battle_id=battle_id, error="duplicate payload hash already retained")) + continue path = raw_path(root, str(item.record["completedAt"]), item.digest) if path.exists(): - raise ValidationError(f"duplicate payload hash: {item.digest}") + persisted.append(ValidationOutcome(index=outcome.index, battle_id=battle_id, error="duplicate payload hash already retained")) + continue write_json(path, item.record) - paths.append(path.relative_to(root).as_posix()) - return paths + battle_ids.add(battle_id) + payload_hashes.add(str(item.record["payloadHash"])) + persisted.append(outcome) + return persisted def main() -> int: @@ -53,11 +71,11 @@ def main() -> int: 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) + outcomes = ingest(arguments.root.resolve(), read_json(arguments.input), account=arguments.account) except (OSError, ValueError, ValidationError) as error: print(f"ingestion failed: {error}") return 1 - print("accepted:\n" + "\n".join(paths)) + print("\n".join(format_outcome(outcome) for outcome in outcomes)) return 0 diff --git a/scripts/validate.py b/scripts/validate.py index 74d75f5..7a2cea0 100644 --- a/scripts/validate.py +++ b/scripts/validate.py @@ -13,7 +13,22 @@ from common import content_hash, read_json SCHEMA_VERSION = 1 -GAME_TYPE_PARTICIPANTS = {"1v1": 2, "twinduel": 4, "melee": 10} +INT32_MIN = -(2**31) +INT32_MAX = 2**31 - 1 +TEAM_SIZE = {"1v1": 1, "twinduel": 2, "melee": 1} +SCORE_FIELDS = ( + "totalScore", + "survival", + "lastSurvivorBonus", + "bulletDamage", + "bulletKillBonus", + "ramDamage", + "ramKillBonus", + "firstPlaces", + "secondPlaces", + "thirdPlaces", +) +PLACE_FIELDS = ("firstPlaces", "secondPlaces", "thirdPlaces") class ValidationError(ValueError): @@ -28,12 +43,34 @@ class AcceptedResult: digest: str +@dataclass(frozen=True) +class ValidationOutcome: + """The accepted or rejected outcome for one submitted record.""" + + index: int + battle_id: str | None + accepted: AcceptedResult | None = None + error: str | None = None + + def require(condition: bool, message: str) -> None: """Raise a diagnostic validation error when condition is false.""" if not condition: raise ValidationError(message) +def require_string(value: Any, message: str) -> str: + """Require a non-empty string value.""" + require(isinstance(value, str) and value, message) + return value + + +def require_int32(value: Any, message: str, *, minimum: int = 0) -> int: + """Require a signed 32-bit integer at or above the supplied minimum.""" + require(type(value) is int and INT32_MIN <= value <= INT32_MAX and value >= minimum, message) + return value + + def registered_client_ids(root: Path, account: str) -> set[str]: """Return registered client IDs for one forge account.""" registration = root / "clients" / f"{account}.json" @@ -53,10 +90,39 @@ def active_bots(root: Path) -> dict[tuple[str, str], dict[str, Any]]: return {(str(bot.get("name")), str(bot.get("version"))): bot for bot in bots if bot.get("status") == "active"} -def validate_result(root: Path, record: Any, *, account: str, client_ids: set[str], known_bots: dict[tuple[str, str], dict[str, Any]]) -> AcceptedResult: +def game_settings(root: Path, game_type: str) -> tuple[dict[str, Any], int]: + """Return validated V1 settings and the number of bots represented by each result entry.""" + engine = read_json(root / "engine.json") + games = engine.get("gameTypes") + require(isinstance(games, dict) and game_type in games, f"unsupported gameType `{game_type}`") + settings = games[game_type] + require(isinstance(settings, dict), f"engine.json has invalid settings for `{game_type}`") + team_size = TEAM_SIZE.get(game_type) + require(team_size is not None, f"unsupported gameType `{game_type}`") + expanded_participants = require_int32(settings.get("participants"), f"engine.json has invalid participants for `{game_type}`", minimum=1) + require(expanded_participants % team_size == 0, f"engine.json has incompatible team size for `{game_type}`") + require_int32(settings.get("rounds"), f"engine.json has invalid rounds for `{game_type}`", minimum=1) + battlefield = settings.get("battlefield") + require(isinstance(battlefield, list) and len(battlefield) == 2, f"engine.json has invalid battlefield for `{game_type}`") + require_int32(battlefield[0], f"engine.json has invalid battlefield for `{game_type}`", minimum=1) + require_int32(battlefield[1], f"engine.json has invalid battlefield for `{game_type}`", minimum=1) + return settings, team_size + + +def validate_ranks(participants: list[dict[str, Any]]) -> None: + """Require the engine's shared 1224 placement rank multiset.""" + ranks = [require_int32(participant.get("rank"), "rank must be a positive signed 32-bit integer", minimum=1) for participant in participants] + distinct = sorted(set(ranks)) + require(distinct and distinct[0] == 1, "the lowest rank must be 1") + for rank in distinct: + lower_count = sum(candidate < rank for candidate in ranks) + require(rank == lower_count + 1, "ranks must use the 1224 placement system") + + +def validate_result(root: Path, record: Any, *, account: str, client_ids: set[str], known_bots: dict[tuple[str, str], dict[str, Any]], envelope_client_id: str, envelope_client_version: str) -> 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", "client", "engine", "gameType", "rounds", "arenaWidth", "arenaHeight", "participants") require(all(field in record for field in required), f"result is missing one of: {', '.join(required)}") try: uuid.UUID(str(record["battleId"])) @@ -66,25 +132,48 @@ def validate_result(root: Path, record: Any, *, account: str, client_ids: set[st datetime.fromisoformat(str(record["completedAt"]).replace("Z", "+00:00")) except ValueError as error: raise ValidationError("completedAt must be an ISO-8601 timestamp") from error - require(record["clientId"] in client_ids, f"clientId `{record['clientId']}` is not registered to `{account}`") - engine = read_json(root / "engine.json") - 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}`") + + client = record["client"] + require(isinstance(client, dict), "client must be an object") + client_id = require_string(client.get("id"), "client.id must be a non-empty string") + client_version = require_string(client.get("version"), "client.version must be a non-empty string") + require(client_id == envelope_client_id and client_version == envelope_client_version, "record client identity must match the envelope") + require(client_id in client_ids, f"client.id `{client_id}` is not registered to `{account}`") + + engine = record["engine"] + require(isinstance(engine, dict), "engine must be an object") + behavior_version = require_int32(engine.get("behaviorVersion"), "engine.behaviorVersion must be a positive signed 32-bit integer", minimum=1) + configured_engine = read_json(root / "engine.json") + require(behavior_version == configured_engine.get("behaviorVersion"), "engine.behaviorVersion does not match engine.json") + + game_type = require_string(record["gameType"], "gameType must be a non-empty string") + settings, team_size = game_settings(root, game_type) + require(record["rounds"] == settings["rounds"], f"rounds does not match the `{game_type}` engine pin") + require(record["arenaWidth"] == settings["battlefield"][0] and record["arenaHeight"] == settings["battlefield"][1], f"arena dimensions do not match the `{game_type}` engine pin") + 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") + expected_entries = settings["participants"] // team_size + require(isinstance(participants, list) and len(participants) == expected_entries, f"{game_type} requires {expected_entries} result entries") identities: set[tuple[str, str]] = set() - total_score = 0.0 + expected_is_team = team_size > 1 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") + name = require_string(participant.get("name"), "participant name must be a non-empty string") + version = require_string(participant.get("version"), "participant version must be a non-empty string") + identity = name, version + require(identity in known_bots, f"unknown or inactive bot `{name} {version}`") + require(identity not in identities, f"bot `{name} {version}` 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") + require(type(participant.get("isTeam")) is bool and participant["isTeam"] is expected_is_team, f"isTeam must be {str(expected_is_team).lower()} for `{game_type}`") + for field in SCORE_FIELDS: + require_int32(participant.get(field), f"{field} must be a non-negative signed 32-bit integer") + + validate_ranks(participants) + completed_rounds = settings["rounds"] * team_size + for field in PLACE_FIELDS: + total = sum(participant[field] for participant in participants) + require(total <= completed_rounds, f"{field} total exceeds completed rounds") + 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,22 +182,27 @@ 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.""" +def validate_envelope(root: Path, envelope: Any, *, account: str) -> list[ValidationOutcome]: + """Validate each record independently and return one outcome for each.""" 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") + client_id = require_string(envelope.get("clientId"), "submission has no clientId") + client_version = require_string(envelope.get("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(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 + outcomes: list[ValidationOutcome] = [] + for index, record in enumerate(records): + battle_id = str(record.get("battleId")) if isinstance(record, dict) and record.get("battleId") is not None else None + try: + accepted = validate_result(root, record, account=account, client_ids=client_ids, known_bots=known_bots, envelope_client_id=client_id, envelope_client_version=client_version) + outcomes.append(ValidationOutcome(index=index, battle_id=battle_id, accepted=accepted)) + except ValidationError as error: + outcomes.append(ValidationOutcome(index=index, battle_id=battle_id, error=str(error))) + return outcomes def main() -> int: @@ -119,11 +213,13 @@ def main() -> int: 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) + outcomes = validate_envelope(arguments.root.resolve(), read_json(arguments.input), account=arguments.account) except (OSError, ValueError, ValidationError) as error: print(f"validation failed: {error}") return 1 - print(f"validated {len(results)} result(s)") + for outcome in outcomes: + label = outcome.battle_id or f"result[{outcome.index}]" + print(f"{label}: {'accepted' if outcome.accepted else f'rejected: {outcome.error}'}") return 0 diff --git a/tests/test_rumble_data.py b/tests/test_rumble_data.py index 3d6373f..7ba70e5 100644 --- a/tests/test_rumble_data.py +++ b/tests/test_rumble_data.py @@ -15,7 +15,6 @@ from aggregate import aggregate from compact import compact from ingest import ingest -from validate import ValidationError class RumbleDataTests(unittest.TestCase): @@ -25,7 +24,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,27 +44,83 @@ 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", "client": {"id": "alice-desktop", "version": "0.1.0"}, "engine": {"behaviorVersion": behavior_version}, + "gameType": "1v1", "rounds": 35, "arenaWidth": 800, "arenaHeight": 600, + "participants": [self.participant("Alpha", rank=1, total_score=80, first_places=35), self.participant("Bravo", rank=2, total_score=20, second_places=35)], }]} + @staticmethod + def participant(name: str, *, rank: int, total_score: int, first_places: int = 0, second_places: int = 0, third_places: int = 0) -> dict: + return {"name": name, "version": "1.0", "isTeam": False, "rank": rank, "totalScore": total_score, "survival": 0, "lastSurvivorBonus": 0, "bulletDamage": 0, "bulletKillBonus": 0, "ramDamage": 0, "ramKillBonus": 0, "firstPlaces": first_places, "secondPlaces": second_places, "thirdPlaces": third_places} + def testRDA001_IntegrationPositive_valid_batch_becomes_immutable_fact_and_projections(self) -> None: - paths = ingest(self.root, self.envelope(), account="alice") - self.assertEqual(1, len(paths)) - self.assertTrue((self.root / paths[0]).is_file()) + outcomes = ingest(self.root, self.envelope(), account="alice") + self.assertTrue(outcomes[0].accepted) + self.assertTrue((self.root / "results/raw/2026/08" / f"{outcomes[0].accepted.digest}.json").is_file()) aggregate(self.root) leaderboard = json.loads((self.root / "leaderboard/1v1.json").read_text(encoding="utf-8")) self.assertEqual("Alpha", leaderboard["entries"][0]["name"]) needed = json.loads((self.root / "matchmaking/matches_needed-1v1.json").read_text(encoding="utf-8")) 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") + def testRDA001_IntegrationPositive_valid_records_survive_invalid_batch_neighbors(self) -> None: + envelope = self.envelope() + invalid = self.envelope(battle_id="a290f1ee-6c54-4b01-90e6-d701748f0851")["results"][0] + invalid["participants"][0].pop("rank") + envelope["results"].append(invalid) + outcomes = ingest(self.root, envelope, account="alice") + self.assertTrue(outcomes[0].accepted) + self.assertIsNone(outcomes[1].accepted) + self.assertIn("rank", outcomes[1].error) + self.assertEqual(1, len(list((self.root / "results/raw").rglob("*.json")))) + + def testRDA002_IntegrationNegative_structural_and_duplicate_records_never_persist(self) -> None: + malformed = self.envelope()["results"][0] + malformed.pop("rounds") + outcome = ingest(self.root, {**self.envelope(), "results": [malformed]}, account="alice")[0] + self.assertIsNone(outcome.accepted) + self.assertIn("rounds", outcome.error) 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") + self.assertTrue(ingest(self.root, self.envelope(), account="alice")[0].accepted) + duplicate = ingest(self.root, self.envelope(), account="alice")[0] + self.assertIsNone(duplicate.accepted) + self.assertIn("duplicate battleId", duplicate.error) + + def testRDA002_IntegrationNegative_rejects_each_documented_structural_violation(self) -> None: + invalid_records = [] + cases = ( + ("a290f1ee-6c54-4b01-90e6-d701748f0851", lambda record: record.update({"client": {"id": "alice-desktop", "version": "other"}}), "record client identity"), + ("b290f1ee-6c54-4b01-90e6-d701748f0851", lambda record: record["engine"].update({"behaviorVersion": True}), "engine.behaviorVersion"), + ("c290f1ee-6c54-4b01-90e6-d701748f0851", lambda record: record.update({"arenaWidth": 801}), "arena dimensions"), + ("d290f1ee-6c54-4b01-90e6-d701748f0852", lambda record: record["participants"][0].update({"isTeam": True}), "isTeam"), + ("e290f1ee-6c54-4b01-90e6-d701748f0851", lambda record: record["participants"][0].update({"totalScore": 1.5}), "totalScore"), + ("f290f1ee-6c54-4b01-90e6-d701748f0851", lambda record: record["participants"][1].update({"rank": 3}), "1224"), + ("0290f1ee-6c54-4b01-90e6-d701748f0851", lambda record: record["participants"][0].update({"firstPlaces": 36}), "firstPlaces"), + ) + for battle_id, mutate, expected_error in cases: + record = self.envelope(battle_id=battle_id)["results"][0] + mutate(record) + invalid_records.append((record, expected_error)) + envelope = self.envelope() + envelope["results"] = [record for record, _ in invalid_records] + outcomes = ingest(self.root, envelope, account="alice") + for outcome, (_, expected_error) in zip(outcomes, invalid_records): + self.assertIsNone(outcome.accepted) + self.assertIn(expected_error, outcome.error) + self.assertFalse((self.root / "results/raw").exists()) + + def testRDA001_IntegrationPositive_twinduel_requires_team_result_entries(self) -> None: + self.write("engine.json", {"schemaVersion": 1, "behaviorVersion": 1, "gameTypes": {"twinduel": {"rounds": 75, "battlefield": [800, 800], "participants": 4}}}) + self.write("catalog.json", {"schemaVersion": 1, "bots": [ + {"name": "Alpha Team", "version": "1.0", "platform": "Python", "owner": "alpha-owner", "status": "active"}, + {"name": "Bravo Team", "version": "1.0", "platform": "Python", "owner": "bravo-owner", "status": "active"}, + ]}) + record = self.envelope()["results"][0] + record.update({"gameType": "twinduel", "rounds": 75, "arenaWidth": 800, "arenaHeight": 800, "participants": [ + {**self.participant("Alpha Team", rank=1, total_score=80, first_places=150), "isTeam": True}, + {**self.participant("Bravo Team", rank=2, total_score=20, second_places=150), "isTeam": True}, + ]}) + self.assertTrue(ingest(self.root, {**self.envelope(), "results": [record]}, account="alice")[0].accepted) def testRDA003_IntegrationPositive_compaction_preserves_deterministic_projection(self) -> None: ingest(self.root, self.envelope(), account="alice") @@ -77,6 +132,18 @@ def testRDA003_IntegrationPositive_compaction_preserves_deterministic_projection after = (self.root / "leaderboard/1v1.json").read_text(encoding="utf-8") self.assertEqual(before, after) + def testRDA003_IntegrationPositive_current_bans_and_registration_filter_existing_facts(self) -> None: + ingest(self.root, self.envelope(), account="alice") + aggregate(self.root) + self.assertEqual(1, json.loads((self.root / "clients.json").read_text(encoding="utf-8"))["clients"][0]["battles"]) + self.write("bans.json", {"schemaVersion": 1, "bannedAccounts": ["alice"], "disqualifiedBots": []}) + aggregate(self.root) + self.assertEqual([], json.loads((self.root / "clients.json").read_text(encoding="utf-8"))["clients"]) + self.write("bans.json", {"schemaVersion": 1, "bannedAccounts": [], "disqualifiedBots": []}) + self.write("clients/alice.json", {"schemaVersion": 1, "account": "alice", "clientIds": []}) + aggregate(self.root) + self.assertEqual([], json.loads((self.root / "clients.json").read_text(encoding="utf-8"))["clients"]) + def testRDA004_E2EPositive_dashboard_references_versioned_projection_and_bot_details(self) -> None: page = (ROOT / "site/index.html").read_text(encoding="utf-8") script = (ROOT / "site/app.js").read_text(encoding="utf-8") From 8fbf117bdff608be4551555b07b65dd3d4bd5915 Mon Sep 17 00:00:00 2001 From: "Flemming N. Larsen" Date: Sun, 2 Aug 2026 22:44:39 +0200 Subject: [PATCH 2/2] fix: cap participant placement totals --- scripts/validate.py | 6 +++++- tests/test_rumble_data.py | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/validate.py b/scripts/validate.py index 7a2cea0..47c79d7 100644 --- a/scripts/validate.py +++ b/scripts/validate.py @@ -156,6 +156,7 @@ def validate_result(root: Path, record: Any, *, account: str, client_ids: set[st require(isinstance(participants, list) and len(participants) == expected_entries, f"{game_type} requires {expected_entries} result entries") identities: set[tuple[str, str]] = set() expected_is_team = team_size > 1 + completed_rounds = settings["rounds"] * team_size for participant in participants: require(isinstance(participant, dict), "each participant must be an object") name = require_string(participant.get("name"), "participant name must be a non-empty string") @@ -167,9 +168,12 @@ def validate_result(root: Path, record: Any, *, account: str, client_ids: set[st require(type(participant.get("isTeam")) is bool and participant["isTeam"] is expected_is_team, f"isTeam must be {str(expected_is_team).lower()} for `{game_type}`") for field in SCORE_FIELDS: require_int32(participant.get(field), f"{field} must be a non-negative signed 32-bit integer") + for field in PLACE_FIELDS: + require(participant[field] <= completed_rounds, f"{field} exceeds completed rounds") validate_ranks(participants) - completed_rounds = settings["rounds"] * team_size + for participant in participants: + require(sum(participant[field] for field in PLACE_FIELDS) <= completed_rounds, "participant place counts exceed completed rounds") for field in PLACE_FIELDS: total = sum(participant[field] for participant in participants) require(total <= completed_rounds, f"{field} total exceeds completed rounds") diff --git a/tests/test_rumble_data.py b/tests/test_rumble_data.py index 7ba70e5..97804b5 100644 --- a/tests/test_rumble_data.py +++ b/tests/test_rumble_data.py @@ -96,6 +96,7 @@ def testRDA002_IntegrationNegative_rejects_each_documented_structural_violation( ("e290f1ee-6c54-4b01-90e6-d701748f0851", lambda record: record["participants"][0].update({"totalScore": 1.5}), "totalScore"), ("f290f1ee-6c54-4b01-90e6-d701748f0851", lambda record: record["participants"][1].update({"rank": 3}), "1224"), ("0290f1ee-6c54-4b01-90e6-d701748f0851", lambda record: record["participants"][0].update({"firstPlaces": 36}), "firstPlaces"), + ("1290f1ee-6c54-4b01-90e6-d701748f0851", lambda record: (record["participants"][0].update({"secondPlaces": 1}), record["participants"][1].update({"secondPlaces": 34})), "participant place counts"), ) for battle_id, mutate, expected_error in cases: record = self.envelope(battle_id=battle_id)["results"][0]