diff --git a/README.md b/README.md index c5a0f1f..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, 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, 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 f77905f..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,11 +38,28 @@ class Bot: platform_key: str config: dict[str, Any] source_hash: str + team_members: tuple["Bot", ...] = () @property 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: + 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_identities(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 +99,27 @@ 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 ` ` 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") + 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 +145,32 @@ def validate_bot(platform_key: str, directory: Path, *, smoke: bool) -> Bot: return bot +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"{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: script = bot.directory / f"{bot.name}.sh" python_executable = str(Path(sys.executable)) @@ -214,7 +267,8 @@ def generated_catalog(bots: list[Bot], root: Path, owner: str) -> tuple[dict[str active = [] 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 = 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 @@ -228,7 +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)] + 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 49d7c57..49c0c96 100644 --- a/tests/test_validate_bot.py +++ b/tests/test_validate_bot.py @@ -36,6 +36,39 @@ 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() + 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 +127,67 @@ 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 1.0.2", "Nova 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", "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 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("is already used by", result.stderr) + if __name__ == "__main__": unittest.main()