From cabddaca2957954b3b8e836f7d05695fe086eae5 Mon Sep 17 00:00:00 2001 From: "Flemming N. Larsen" Date: Sun, 30 Aug 2026 13:26:56 +0200 Subject: [PATCH 1/2] feat: publish TwinDuel team membership --- README.md | 2 +- scripts/validate_bot.py | 42 ++++++++++++++++++++++++++++++++++---- tests/test_validate_bot.py | 32 +++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index c5a0f1f..62d3ec7 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Read [CONTRIBUTING.md](CONTRIBUTING.md) for the full submission, ownership, lice ## Catalog -`bots/index.json` and `bots/owners.json` are generated by CI and must never be edited in a pull request. A catalog entry contains the bot identity, owner, source path, source-tree SHA-256, and active lifecycle status. Only `active` entries are eligible for matchmaking. +`bots/index.json` and `bots/owners.json` are generated by CI and must never be edited in a pull request. A catalog entry contains the bot identity, owner, source path, source-tree SHA-256, active lifecycle status, and `teamMembers`. Individual bots publish an empty member list; a TwinDuel team publishes exactly two active member identities. Only `active` entries are eligible for matchmaking. This repository is designed to be forkable: its validator is standard-library Python and GitHub Actions only invokes that script. The only forge seam is the workflow that supplies the pull-request author to the validator and publishes generated files after merge. Source-only catalog for ranked Tank Royale Rumble bots diff --git a/scripts/validate_bot.py b/scripts/validate_bot.py index f77905f..ee0bcbd 100644 --- a/scripts/validate_bot.py +++ b/scripts/validate_bot.py @@ -43,6 +43,19 @@ class Bot: def name(self) -> str: return str(self.config["name"]) + @property + def display_name(self) -> str: + return f"{self.name} {self.config['version']}" + + @property + def platform(self) -> str: + return PLATFORMS[self.platform_key][1] + + @property + def team_member_names(self) -> list[str]: + value = self.config.get("teamMembers", []) + return list(value) if isinstance(value, list) else [] + def read_json(path: Path) -> dict[str, Any]: try: @@ -82,17 +95,24 @@ def bot_directories(root: Path) -> list[tuple[str, Path]]: def validate_bot(platform_key: str, directory: Path, *, smoke: bool) -> Bot: source_extension, expected_platform, api_token = PLATFORMS[platform_key] config = read_json(directory / f"{directory.name}.json") - for field in ("name", "version", "authors", "platform", "license"): + for field in ("name", "version", "authors", "license"): if not config.get(field): raise ValidationError(f"{directory}: missing required `{field}` in {directory.name}.json") if config["name"] != directory.name: raise ValidationError(f"{directory}: directory name must equal config name `{config['name']}`") - if config["platform"] != expected_platform: - raise ValidationError(f"{directory}: `{platform_key}` entries require platform `{expected_platform}`") if not isinstance(config["authors"], list) or not all(isinstance(author, str) and author for author in config["authors"]): raise ValidationError(f"{directory}: `authors` must be a non-empty list of display names") if config["license"] not in ALLOWED_LICENSES: raise ValidationError(f"{directory}: `license` must be one of {', '.join(sorted(ALLOWED_LICENSES))}") + if "teamMembers" in config: + members = config["teamMembers"] + if not isinstance(members, list) or len(members) != 2 or not all(isinstance(member, str) and member for member in members): + raise ValidationError(f"{directory}: `teamMembers` must contain exactly two directory names") + return Bot(directory, platform_key, config, tree_hash(directory)) + if not config.get("platform"): + raise ValidationError(f"{directory}: missing required `platform` in {directory.name}.json") + if config["platform"] != expected_platform: + raise ValidationError(f"{directory}: `{platform_key}` entries require platform `{expected_platform}`") for suffix in (".sh", ".cmd"): if not (directory / f"{directory.name}{suffix}").is_file(): raise ValidationError(f"{directory}: missing required {directory.name}{suffix} boot script") @@ -118,6 +138,17 @@ def validate_bot(platform_key: str, directory: Path, *, smoke: bool) -> Bot: return bot +def validate_team_members(bots: list[Bot]) -> None: + by_directory = {bot.directory: bot for bot in bots} + for team in (bot for bot in bots if bot.team_member_names): + for member_name in team.team_member_names: + member = by_directory.get(team.directory.parent / member_name) + if member is None: + raise ValidationError(f"{team.directory}: unknown team member `{member_name}`") + if member.team_member_names: + raise ValidationError(f"{team.directory}: team member `{member_name}` cannot be another team") + + def smoke_bot(bot: Bot) -> None: script = bot.directory / f"{bot.name}.sh" python_executable = str(Path(sys.executable)) @@ -212,9 +243,11 @@ def generated_catalog(bots: list[Bot], root: Path, owner: str) -> tuple[dict[str elif current is None: history.append(entry) active = [] + by_directory = {bot.directory: bot for bot in bots} for bot in sorted(bots, key=lambda item: item.name.casefold()): previous = next((entry for entry in existing_catalog.get("bots", []) if entry.get("name") == bot.name and entry.get("version") == bot.config["version"]), None) - active.append({"name": bot.name, "version": bot.config["version"], "platform": bot.config["platform"], "path": bot.directory.relative_to(root).as_posix(), "sourceHash": bot.source_hash, "owner": owner_by_bot.get(bot.name, owner), "authors": bot.config["authors"], "addedAt": previous.get("addedAt", today) if previous else today, "status": "active"}) + team_members = [by_directory[bot.directory.parent / name].display_name for name in bot.team_member_names] + active.append({"name": bot.name, "version": bot.config["version"], "platform": bot.platform, "path": bot.directory.relative_to(root).as_posix(), "sourceHash": bot.source_hash, "owner": owner_by_bot.get(bot.name, owner), "authors": bot.config["authors"], "addedAt": previous.get("addedAt", today) if previous else today, "status": "active", "teamMembers": team_members}) catalog = {"schemaVersion": 1, "generatedAt": datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z"), "commit": os.environ.get("GITHUB_SHA", "local"), "bots": history + active} return catalog, owner_data @@ -229,6 +262,7 @@ def main() -> int: root = arguments.root.resolve() try: bots = [validate_bot(platform, directory, smoke=arguments.smoke) for platform, directory in bot_directories(root)] + validate_team_members(bots) check_governance(bots, root, arguments.owner) if arguments.generate: catalog, owners = generated_catalog(bots, root, arguments.owner) diff --git a/tests/test_validate_bot.py b/tests/test_validate_bot.py index 49d7c57..55bcd66 100644 --- a/tests/test_validate_bot.py +++ b/tests/test_validate_bot.py @@ -36,6 +36,18 @@ def add_bot(self, name: str) -> None: config_path.unlink() (destination / f"{name}.json").write_text(json.dumps(config), encoding="utf-8") + def add_team(self, name: str, members: list[str]) -> None: + destination = self.root / "bots" / "python" / name + destination.mkdir() + config = { + "name": name, + "version": "1.0", + "authors": ["Test author"], + "license": "Apache-2.0", + "teamMembers": members, + } + (destination / f"{name}.json").write_text(json.dumps(config), encoding="utf-8") + def test_valid_submission_generates_an_active_catalog_entry(self) -> None: result = self.run_validator("--smoke", "--generate") self.assertEqual(0, result.returncode, result.stderr) @@ -94,6 +106,26 @@ def test_registered_secondary_account_can_update_and_is_preserved(self) -> None: regenerated_owners = json.loads(owners_path.read_text(encoding="utf-8")) self.assertEqual(["primary", "secondary"], regenerated_owners["owners"][0]["accounts"]) + def test_RBC004_IntegrationPositive_team_members_are_published_as_catalog_identities(self) -> None: + self.add_bot("Nova") + self.add_team("OrbitNova", ["Orbit", "Nova"]) + + result = self.run_validator("--generate") + + self.assertEqual(0, result.returncode, result.stderr) + catalog = json.loads((self.root / "bots" / "index.json").read_text(encoding="utf-8")) + entries = {entry["name"]: entry for entry in catalog["bots"] if entry["status"] == "active"} + self.assertEqual(["Orbit 1.0.2", "Nova 1.0.2"], entries["OrbitNova"]["teamMembers"]) + self.assertEqual([], entries["Orbit"]["teamMembers"]) + + def test_RBC004_IntegrationNegative_unknown_team_member_is_rejected(self) -> None: + self.add_team("BrokenTeam", ["Orbit", "Missing"]) + + result = self.run_validator("--generate") + + self.assertNotEqual(0, result.returncode) + self.assertIn("unknown team member `Missing`", result.stderr) + if __name__ == "__main__": unittest.main() From d58e6b5398e28637d3b72614cd592b6c1d2eec20 Mon Sep 17 00:00:00 2001 From: "Flemming N. Larsen" Date: Sun, 30 Aug 2026 14:04:33 +0200 Subject: [PATCH 2/2] fix: harden TwinDuel team catalog entries Team directories returned from validation before the binary, archive, and forbidden-token scans, so a team could publish arbitrary content that the client would then materialize. A team directory now carries nothing but its own JSON. teamMembers held bare directory names and a team's sourceHash covered only its own directory, so a member version bump rewrote the published membership while the team's own name and version stayed put, silently changing what an immutable result identity means. Members are now declared as full ` ` identities, so a member release forces a team resubmission that the existing version-bump rule can see. Members were resolved as siblings of the team directory, which forbade a cross-platform team and published whichever platform the team directory happened to sit under. Members now resolve by identity across every platform directory, bot names must be globally unique, and a team publishes its members' shared platform or `Mixed`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Hag2ofqnbadWJqvnboJU7s --- README.md | 2 +- scripts/validate_bot.py | 50 +++++++++++++++++++--------- tests/test_validate_bot.py | 68 ++++++++++++++++++++++++++++++++++++-- 3 files changed, 101 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 62d3ec7..ae90aeb 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Read [CONTRIBUTING.md](CONTRIBUTING.md) for the full submission, ownership, lice ## Catalog -`bots/index.json` and `bots/owners.json` are generated by CI and must never be edited in a pull request. A catalog entry contains the bot identity, owner, source path, source-tree SHA-256, active lifecycle status, and `teamMembers`. Individual bots publish an empty member list; a TwinDuel team publishes exactly two active member identities. Only `active` entries are eligible for matchmaking. +`bots/index.json` and `bots/owners.json` are generated by CI and must never be edited in a pull request. A catalog entry contains the bot identity, owner, source path, source-tree SHA-256, active lifecycle status, and `teamMembers`. Individual bots publish an empty member list; a TwinDuel team publishes exactly two active ` ` member identities, so a member release always forces the team entry to be resubmitted. A team directory contains nothing but its own JSON, its members may live under different platform directories, and its published platform is the members' shared platform or `Mixed`. Only `active` entries are eligible for matchmaking. This repository is designed to be forkable: its validator is standard-library Python and GitHub Actions only invokes that script. The only forge seam is the workflow that supplies the pull-request author to the validator and publishes generated files after merge. Source-only catalog for ranked Tank Royale Rumble bots diff --git a/scripts/validate_bot.py b/scripts/validate_bot.py index ee0bcbd..ee3998e 100644 --- a/scripts/validate_bot.py +++ b/scripts/validate_bot.py @@ -11,7 +11,7 @@ import subprocess import sys import unicodedata -from dataclasses import dataclass +from dataclasses import dataclass, replace from datetime import UTC, datetime from pathlib import Path from typing import Any @@ -38,6 +38,7 @@ class Bot: platform_key: str config: dict[str, Any] source_hash: str + team_members: tuple["Bot", ...] = () @property def name(self) -> str: @@ -49,10 +50,13 @@ def display_name(self) -> str: @property def platform(self) -> str: + if self.team_members: + platforms = sorted({member.platform for member in self.team_members}) + return platforms[0] if len(platforms) == 1 else "Mixed" return PLATFORMS[self.platform_key][1] @property - def team_member_names(self) -> list[str]: + def team_member_identities(self) -> list[str]: value = self.config.get("teamMembers", []) return list(value) if isinstance(value, list) else [] @@ -107,7 +111,10 @@ def validate_bot(platform_key: str, directory: Path, *, smoke: bool) -> Bot: if "teamMembers" in config: members = config["teamMembers"] if not isinstance(members, list) or len(members) != 2 or not all(isinstance(member, str) and member for member in members): - raise ValidationError(f"{directory}: `teamMembers` must contain exactly two directory names") + raise ValidationError(f"{directory}: `teamMembers` must contain exactly two ` ` member identities") + extra = sorted(path.relative_to(directory).as_posix() for path in directory.rglob("*") if path.name != f"{directory.name}.json") + if extra: + raise ValidationError(f"{directory}: a team directory must contain only {directory.name}.json, found {', '.join(extra)}") return Bot(directory, platform_key, config, tree_hash(directory)) if not config.get("platform"): raise ValidationError(f"{directory}: missing required `platform` in {directory.name}.json") @@ -138,15 +145,30 @@ def validate_bot(platform_key: str, directory: Path, *, smoke: bool) -> Bot: return bot -def validate_team_members(bots: list[Bot]) -> None: - by_directory = {bot.directory: bot for bot in bots} - for team in (bot for bot in bots if bot.team_member_names): - for member_name in team.team_member_names: - member = by_directory.get(team.directory.parent / member_name) +def resolve_teams(bots: list[Bot]) -> list[Bot]: + """Bind every team to its member bots, which may live under any platform directory.""" + by_name: dict[str, Bot] = {} + for bot in bots: + duplicate = by_name.get(bot.name) + if duplicate is not None: + raise ValidationError(f"{bot.directory}: bot name `{bot.name}` is already used by {duplicate.directory}") + by_name[bot.name] = bot + by_identity = {bot.display_name: bot for bot in bots} + resolved: list[Bot] = [] + for bot in bots: + if not bot.team_member_identities: + resolved.append(bot) + continue + members: list[Bot] = [] + for identity in bot.team_member_identities: + member = by_identity.get(identity) if member is None: - raise ValidationError(f"{team.directory}: unknown team member `{member_name}`") - if member.team_member_names: - raise ValidationError(f"{team.directory}: team member `{member_name}` cannot be another team") + raise ValidationError(f"{bot.directory}: unknown team member `{identity}`") + if member.team_member_identities: + raise ValidationError(f"{bot.directory}: team member `{identity}` cannot be another team") + members.append(member) + resolved.append(replace(bot, team_members=tuple(members))) + return resolved def smoke_bot(bot: Bot) -> None: @@ -243,10 +265,9 @@ def generated_catalog(bots: list[Bot], root: Path, owner: str) -> tuple[dict[str elif current is None: history.append(entry) active = [] - by_directory = {bot.directory: bot for bot in bots} for bot in sorted(bots, key=lambda item: item.name.casefold()): previous = next((entry for entry in existing_catalog.get("bots", []) if entry.get("name") == bot.name and entry.get("version") == bot.config["version"]), None) - team_members = [by_directory[bot.directory.parent / name].display_name for name in bot.team_member_names] + team_members = list(bot.team_member_identities) active.append({"name": bot.name, "version": bot.config["version"], "platform": bot.platform, "path": bot.directory.relative_to(root).as_posix(), "sourceHash": bot.source_hash, "owner": owner_by_bot.get(bot.name, owner), "authors": bot.config["authors"], "addedAt": previous.get("addedAt", today) if previous else today, "status": "active", "teamMembers": team_members}) catalog = {"schemaVersion": 1, "generatedAt": datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z"), "commit": os.environ.get("GITHUB_SHA", "local"), "bots": history + active} return catalog, owner_data @@ -261,8 +282,7 @@ def main() -> int: arguments = parser.parse_args() root = arguments.root.resolve() try: - bots = [validate_bot(platform, directory, smoke=arguments.smoke) for platform, directory in bot_directories(root)] - validate_team_members(bots) + bots = resolve_teams([validate_bot(platform, directory, smoke=arguments.smoke) for platform, directory in bot_directories(root)]) check_governance(bots, root, arguments.owner) if arguments.generate: catalog, owners = generated_catalog(bots, root, arguments.owner) diff --git a/tests/test_validate_bot.py b/tests/test_validate_bot.py index 55bcd66..49c0c96 100644 --- a/tests/test_validate_bot.py +++ b/tests/test_validate_bot.py @@ -36,6 +36,27 @@ def add_bot(self, name: str) -> None: config_path.unlink() (destination / f"{name}.json").write_text(json.dumps(config), encoding="utf-8") + def bump_version(self, name: str, version: str) -> None: + config_path = self.root / "bots" / "python" / name / f"{name}.json" + config = json.loads(config_path.read_text(encoding="utf-8")) + config["version"] = version + config_path.write_text(json.dumps(config), encoding="utf-8") + + def add_java_bot(self, name: str) -> None: + destination = self.root / "bots" / "java" / name + destination.mkdir(parents=True) + config = { + "name": name, + "version": "1.0.2", + "authors": ["Test author"], + "license": "Apache-2.0", + "platform": "JVM", + } + (destination / f"{name}.json").write_text(json.dumps(config), encoding="utf-8") + (destination / f"{name}.java").write_text("// dev.robocode.tankroyale.botapi", encoding="utf-8") + for suffix in (".sh", ".cmd"): + (destination / f"{name}{suffix}").write_text("", encoding="utf-8") + def add_team(self, name: str, members: list[str]) -> None: destination = self.root / "bots" / "python" / name destination.mkdir() @@ -108,7 +129,7 @@ def test_registered_secondary_account_can_update_and_is_preserved(self) -> None: def test_RBC004_IntegrationPositive_team_members_are_published_as_catalog_identities(self) -> None: self.add_bot("Nova") - self.add_team("OrbitNova", ["Orbit", "Nova"]) + self.add_team("OrbitNova", ["Orbit 1.0.2", "Nova 1.0.2"]) result = self.run_validator("--generate") @@ -116,15 +137,56 @@ def test_RBC004_IntegrationPositive_team_members_are_published_as_catalog_identi catalog = json.loads((self.root / "bots" / "index.json").read_text(encoding="utf-8")) entries = {entry["name"]: entry for entry in catalog["bots"] if entry["status"] == "active"} self.assertEqual(["Orbit 1.0.2", "Nova 1.0.2"], entries["OrbitNova"]["teamMembers"]) + self.assertEqual("Python", entries["OrbitNova"]["platform"]) self.assertEqual([], entries["Orbit"]["teamMembers"]) def test_RBC004_IntegrationNegative_unknown_team_member_is_rejected(self) -> None: - self.add_team("BrokenTeam", ["Orbit", "Missing"]) + self.add_team("BrokenTeam", ["Orbit 1.0.2", "Missing 1.0"]) + + result = self.run_validator("--generate") + + self.assertNotEqual(0, result.returncode) + self.assertIn("unknown team member `Missing 1.0`", result.stderr) + + def test_RBC004_IntegrationNegative_member_version_bump_invalidates_the_team_identity(self) -> None: + self.add_bot("Nova") + self.add_team("OrbitNova", ["Orbit 1.0.2", "Nova 1.0.2"]) + self.assertEqual(0, self.run_validator("--generate").returncode) + self.bump_version("Nova", "1.0.3") + + result = self.run_validator("--generate") + + self.assertNotEqual(0, result.returncode) + self.assertIn("unknown team member `Nova 1.0.2`", result.stderr) + + def test_RBC004_IntegrationNegative_a_team_directory_may_not_carry_sources(self) -> None: + self.add_team("OrbitOrbit", ["Orbit 1.0.2", "Orbit 1.0.2"]) + (self.root / "bots" / "python" / "OrbitOrbit" / "payload.py").write_text("print()", encoding="utf-8") + + result = self.run_validator("--generate") + + self.assertNotEqual(0, result.returncode) + self.assertIn("must contain only OrbitOrbit.json", result.stderr) + + def test_RBC004_IntegrationPositive_a_team_may_span_two_platforms(self) -> None: + self.add_java_bot("Comet") + self.add_team("OrbitComet", ["Orbit 1.0.2", "Comet 1.0.2"]) + + result = self.run_validator("--generate") + + self.assertEqual(0, result.returncode, result.stderr) + catalog = json.loads((self.root / "bots" / "index.json").read_text(encoding="utf-8")) + entries = {entry["name"]: entry for entry in catalog["bots"] if entry["status"] == "active"} + self.assertEqual(["Orbit 1.0.2", "Comet 1.0.2"], entries["OrbitComet"]["teamMembers"]) + self.assertEqual("Mixed", entries["OrbitComet"]["platform"]) + + def test_RBC004_IntegrationNegative_duplicate_bot_names_across_platforms_are_rejected(self) -> None: + self.add_java_bot("Orbit") result = self.run_validator("--generate") self.assertNotEqual(0, result.returncode) - self.assertIn("unknown team member `Missing`", result.stderr) + self.assertIn("is already used by", result.stderr) if __name__ == "__main__":