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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<name> <version>` 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
66 changes: 60 additions & 6 deletions scripts/validate_bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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 `<name> <version>` 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")
Expand All @@ -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))
Expand Down Expand Up @@ -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

Expand All @@ -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)
Expand Down
94 changes: 94 additions & 0 deletions tests/test_validate_bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Loading