Skip to content

Commit 8ba8c99

Browse files
Merge pull request #3 from robocode-dev/ch-031-team-catalog
Publish TwinDuel team membership
2 parents 01e245a + d58e6b5 commit 8ba8c99

3 files changed

Lines changed: 155 additions & 7 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Read [CONTRIBUTING.md](CONTRIBUTING.md) for the full submission, ownership, lice
1313

1414
## Catalog
1515

16-
`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.
16+
`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.
1717

1818
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.
1919
Source-only catalog for ranked Tank Royale Rumble bots

scripts/validate_bot.py

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import subprocess
1212
import sys
1313
import unicodedata
14-
from dataclasses import dataclass
14+
from dataclasses import dataclass, replace
1515
from datetime import UTC, datetime
1616
from pathlib import Path
1717
from typing import Any
@@ -38,11 +38,28 @@ class Bot:
3838
platform_key: str
3939
config: dict[str, Any]
4040
source_hash: str
41+
team_members: tuple["Bot", ...] = ()
4142

4243
@property
4344
def name(self) -> str:
4445
return str(self.config["name"])
4546

47+
@property
48+
def display_name(self) -> str:
49+
return f"{self.name} {self.config['version']}"
50+
51+
@property
52+
def platform(self) -> str:
53+
if self.team_members:
54+
platforms = sorted({member.platform for member in self.team_members})
55+
return platforms[0] if len(platforms) == 1 else "Mixed"
56+
return PLATFORMS[self.platform_key][1]
57+
58+
@property
59+
def team_member_identities(self) -> list[str]:
60+
value = self.config.get("teamMembers", [])
61+
return list(value) if isinstance(value, list) else []
62+
4663

4764
def read_json(path: Path) -> dict[str, Any]:
4865
try:
@@ -82,17 +99,27 @@ def bot_directories(root: Path) -> list[tuple[str, Path]]:
8299
def validate_bot(platform_key: str, directory: Path, *, smoke: bool) -> Bot:
83100
source_extension, expected_platform, api_token = PLATFORMS[platform_key]
84101
config = read_json(directory / f"{directory.name}.json")
85-
for field in ("name", "version", "authors", "platform", "license"):
102+
for field in ("name", "version", "authors", "license"):
86103
if not config.get(field):
87104
raise ValidationError(f"{directory}: missing required `{field}` in {directory.name}.json")
88105
if config["name"] != directory.name:
89106
raise ValidationError(f"{directory}: directory name must equal config name `{config['name']}`")
90-
if config["platform"] != expected_platform:
91-
raise ValidationError(f"{directory}: `{platform_key}` entries require platform `{expected_platform}`")
92107
if not isinstance(config["authors"], list) or not all(isinstance(author, str) and author for author in config["authors"]):
93108
raise ValidationError(f"{directory}: `authors` must be a non-empty list of display names")
94109
if config["license"] not in ALLOWED_LICENSES:
95110
raise ValidationError(f"{directory}: `license` must be one of {', '.join(sorted(ALLOWED_LICENSES))}")
111+
if "teamMembers" in config:
112+
members = config["teamMembers"]
113+
if not isinstance(members, list) or len(members) != 2 or not all(isinstance(member, str) and member for member in members):
114+
raise ValidationError(f"{directory}: `teamMembers` must contain exactly two `<name> <version>` member identities")
115+
extra = sorted(path.relative_to(directory).as_posix() for path in directory.rglob("*") if path.name != f"{directory.name}.json")
116+
if extra:
117+
raise ValidationError(f"{directory}: a team directory must contain only {directory.name}.json, found {', '.join(extra)}")
118+
return Bot(directory, platform_key, config, tree_hash(directory))
119+
if not config.get("platform"):
120+
raise ValidationError(f"{directory}: missing required `platform` in {directory.name}.json")
121+
if config["platform"] != expected_platform:
122+
raise ValidationError(f"{directory}: `{platform_key}` entries require platform `{expected_platform}`")
96123
for suffix in (".sh", ".cmd"):
97124
if not (directory / f"{directory.name}{suffix}").is_file():
98125
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:
118145
return bot
119146

120147

148+
def resolve_teams(bots: list[Bot]) -> list[Bot]:
149+
"""Bind every team to its member bots, which may live under any platform directory."""
150+
by_name: dict[str, Bot] = {}
151+
for bot in bots:
152+
duplicate = by_name.get(bot.name)
153+
if duplicate is not None:
154+
raise ValidationError(f"{bot.directory}: bot name `{bot.name}` is already used by {duplicate.directory}")
155+
by_name[bot.name] = bot
156+
by_identity = {bot.display_name: bot for bot in bots}
157+
resolved: list[Bot] = []
158+
for bot in bots:
159+
if not bot.team_member_identities:
160+
resolved.append(bot)
161+
continue
162+
members: list[Bot] = []
163+
for identity in bot.team_member_identities:
164+
member = by_identity.get(identity)
165+
if member is None:
166+
raise ValidationError(f"{bot.directory}: unknown team member `{identity}`")
167+
if member.team_member_identities:
168+
raise ValidationError(f"{bot.directory}: team member `{identity}` cannot be another team")
169+
members.append(member)
170+
resolved.append(replace(bot, team_members=tuple(members)))
171+
return resolved
172+
173+
121174
def smoke_bot(bot: Bot) -> None:
122175
script = bot.directory / f"{bot.name}.sh"
123176
python_executable = str(Path(sys.executable))
@@ -214,7 +267,8 @@ def generated_catalog(bots: list[Bot], root: Path, owner: str) -> tuple[dict[str
214267
active = []
215268
for bot in sorted(bots, key=lambda item: item.name.casefold()):
216269
previous = next((entry for entry in existing_catalog.get("bots", []) if entry.get("name") == bot.name and entry.get("version") == bot.config["version"]), None)
217-
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"})
270+
team_members = list(bot.team_member_identities)
271+
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})
218272
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}
219273
return catalog, owner_data
220274

@@ -228,7 +282,7 @@ def main() -> int:
228282
arguments = parser.parse_args()
229283
root = arguments.root.resolve()
230284
try:
231-
bots = [validate_bot(platform, directory, smoke=arguments.smoke) for platform, directory in bot_directories(root)]
285+
bots = resolve_teams([validate_bot(platform, directory, smoke=arguments.smoke) for platform, directory in bot_directories(root)])
232286
check_governance(bots, root, arguments.owner)
233287
if arguments.generate:
234288
catalog, owners = generated_catalog(bots, root, arguments.owner)

tests/test_validate_bot.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,39 @@ def add_bot(self, name: str) -> None:
3636
config_path.unlink()
3737
(destination / f"{name}.json").write_text(json.dumps(config), encoding="utf-8")
3838

39+
def bump_version(self, name: str, version: str) -> None:
40+
config_path = self.root / "bots" / "python" / name / f"{name}.json"
41+
config = json.loads(config_path.read_text(encoding="utf-8"))
42+
config["version"] = version
43+
config_path.write_text(json.dumps(config), encoding="utf-8")
44+
45+
def add_java_bot(self, name: str) -> None:
46+
destination = self.root / "bots" / "java" / name
47+
destination.mkdir(parents=True)
48+
config = {
49+
"name": name,
50+
"version": "1.0.2",
51+
"authors": ["Test author"],
52+
"license": "Apache-2.0",
53+
"platform": "JVM",
54+
}
55+
(destination / f"{name}.json").write_text(json.dumps(config), encoding="utf-8")
56+
(destination / f"{name}.java").write_text("// dev.robocode.tankroyale.botapi", encoding="utf-8")
57+
for suffix in (".sh", ".cmd"):
58+
(destination / f"{name}{suffix}").write_text("", encoding="utf-8")
59+
60+
def add_team(self, name: str, members: list[str]) -> None:
61+
destination = self.root / "bots" / "python" / name
62+
destination.mkdir()
63+
config = {
64+
"name": name,
65+
"version": "1.0",
66+
"authors": ["Test author"],
67+
"license": "Apache-2.0",
68+
"teamMembers": members,
69+
}
70+
(destination / f"{name}.json").write_text(json.dumps(config), encoding="utf-8")
71+
3972
def test_valid_submission_generates_an_active_catalog_entry(self) -> None:
4073
result = self.run_validator("--smoke", "--generate")
4174
self.assertEqual(0, result.returncode, result.stderr)
@@ -94,6 +127,67 @@ def test_registered_secondary_account_can_update_and_is_preserved(self) -> None:
94127
regenerated_owners = json.loads(owners_path.read_text(encoding="utf-8"))
95128
self.assertEqual(["primary", "secondary"], regenerated_owners["owners"][0]["accounts"])
96129

130+
def test_RBC004_IntegrationPositive_team_members_are_published_as_catalog_identities(self) -> None:
131+
self.add_bot("Nova")
132+
self.add_team("OrbitNova", ["Orbit 1.0.2", "Nova 1.0.2"])
133+
134+
result = self.run_validator("--generate")
135+
136+
self.assertEqual(0, result.returncode, result.stderr)
137+
catalog = json.loads((self.root / "bots" / "index.json").read_text(encoding="utf-8"))
138+
entries = {entry["name"]: entry for entry in catalog["bots"] if entry["status"] == "active"}
139+
self.assertEqual(["Orbit 1.0.2", "Nova 1.0.2"], entries["OrbitNova"]["teamMembers"])
140+
self.assertEqual("Python", entries["OrbitNova"]["platform"])
141+
self.assertEqual([], entries["Orbit"]["teamMembers"])
142+
143+
def test_RBC004_IntegrationNegative_unknown_team_member_is_rejected(self) -> None:
144+
self.add_team("BrokenTeam", ["Orbit 1.0.2", "Missing 1.0"])
145+
146+
result = self.run_validator("--generate")
147+
148+
self.assertNotEqual(0, result.returncode)
149+
self.assertIn("unknown team member `Missing 1.0`", result.stderr)
150+
151+
def test_RBC004_IntegrationNegative_member_version_bump_invalidates_the_team_identity(self) -> None:
152+
self.add_bot("Nova")
153+
self.add_team("OrbitNova", ["Orbit 1.0.2", "Nova 1.0.2"])
154+
self.assertEqual(0, self.run_validator("--generate").returncode)
155+
self.bump_version("Nova", "1.0.3")
156+
157+
result = self.run_validator("--generate")
158+
159+
self.assertNotEqual(0, result.returncode)
160+
self.assertIn("unknown team member `Nova 1.0.2`", result.stderr)
161+
162+
def test_RBC004_IntegrationNegative_a_team_directory_may_not_carry_sources(self) -> None:
163+
self.add_team("OrbitOrbit", ["Orbit 1.0.2", "Orbit 1.0.2"])
164+
(self.root / "bots" / "python" / "OrbitOrbit" / "payload.py").write_text("print()", encoding="utf-8")
165+
166+
result = self.run_validator("--generate")
167+
168+
self.assertNotEqual(0, result.returncode)
169+
self.assertIn("must contain only OrbitOrbit.json", result.stderr)
170+
171+
def test_RBC004_IntegrationPositive_a_team_may_span_two_platforms(self) -> None:
172+
self.add_java_bot("Comet")
173+
self.add_team("OrbitComet", ["Orbit 1.0.2", "Comet 1.0.2"])
174+
175+
result = self.run_validator("--generate")
176+
177+
self.assertEqual(0, result.returncode, result.stderr)
178+
catalog = json.loads((self.root / "bots" / "index.json").read_text(encoding="utf-8"))
179+
entries = {entry["name"]: entry for entry in catalog["bots"] if entry["status"] == "active"}
180+
self.assertEqual(["Orbit 1.0.2", "Comet 1.0.2"], entries["OrbitComet"]["teamMembers"])
181+
self.assertEqual("Mixed", entries["OrbitComet"]["platform"])
182+
183+
def test_RBC004_IntegrationNegative_duplicate_bot_names_across_platforms_are_rejected(self) -> None:
184+
self.add_java_bot("Orbit")
185+
186+
result = self.run_validator("--generate")
187+
188+
self.assertNotEqual(0, result.returncode)
189+
self.assertIn("is already used by", result.stderr)
190+
97191

98192
if __name__ == "__main__":
99193
unittest.main()

0 commit comments

Comments
 (0)