Skip to content

Commit cabddac

Browse files
feat: publish TwinDuel team membership
1 parent 01e245a commit cabddac

3 files changed

Lines changed: 71 additions & 5 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 member identities. 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: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,19 @@ class Bot:
4343
def name(self) -> str:
4444
return str(self.config["name"])
4545

46+
@property
47+
def display_name(self) -> str:
48+
return f"{self.name} {self.config['version']}"
49+
50+
@property
51+
def platform(self) -> str:
52+
return PLATFORMS[self.platform_key][1]
53+
54+
@property
55+
def team_member_names(self) -> list[str]:
56+
value = self.config.get("teamMembers", [])
57+
return list(value) if isinstance(value, list) else []
58+
4659

4760
def read_json(path: Path) -> dict[str, Any]:
4861
try:
@@ -82,17 +95,24 @@ def bot_directories(root: Path) -> list[tuple[str, Path]]:
8295
def validate_bot(platform_key: str, directory: Path, *, smoke: bool) -> Bot:
8396
source_extension, expected_platform, api_token = PLATFORMS[platform_key]
8497
config = read_json(directory / f"{directory.name}.json")
85-
for field in ("name", "version", "authors", "platform", "license"):
98+
for field in ("name", "version", "authors", "license"):
8699
if not config.get(field):
87100
raise ValidationError(f"{directory}: missing required `{field}` in {directory.name}.json")
88101
if config["name"] != directory.name:
89102
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}`")
92103
if not isinstance(config["authors"], list) or not all(isinstance(author, str) and author for author in config["authors"]):
93104
raise ValidationError(f"{directory}: `authors` must be a non-empty list of display names")
94105
if config["license"] not in ALLOWED_LICENSES:
95106
raise ValidationError(f"{directory}: `license` must be one of {', '.join(sorted(ALLOWED_LICENSES))}")
107+
if "teamMembers" in config:
108+
members = config["teamMembers"]
109+
if not isinstance(members, list) or len(members) != 2 or not all(isinstance(member, str) and member for member in members):
110+
raise ValidationError(f"{directory}: `teamMembers` must contain exactly two directory names")
111+
return Bot(directory, platform_key, config, tree_hash(directory))
112+
if not config.get("platform"):
113+
raise ValidationError(f"{directory}: missing required `platform` in {directory.name}.json")
114+
if config["platform"] != expected_platform:
115+
raise ValidationError(f"{directory}: `{platform_key}` entries require platform `{expected_platform}`")
96116
for suffix in (".sh", ".cmd"):
97117
if not (directory / f"{directory.name}{suffix}").is_file():
98118
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:
118138
return bot
119139

120140

141+
def validate_team_members(bots: list[Bot]) -> None:
142+
by_directory = {bot.directory: bot for bot in bots}
143+
for team in (bot for bot in bots if bot.team_member_names):
144+
for member_name in team.team_member_names:
145+
member = by_directory.get(team.directory.parent / member_name)
146+
if member is None:
147+
raise ValidationError(f"{team.directory}: unknown team member `{member_name}`")
148+
if member.team_member_names:
149+
raise ValidationError(f"{team.directory}: team member `{member_name}` cannot be another team")
150+
151+
121152
def smoke_bot(bot: Bot) -> None:
122153
script = bot.directory / f"{bot.name}.sh"
123154
python_executable = str(Path(sys.executable))
@@ -212,9 +243,11 @@ def generated_catalog(bots: list[Bot], root: Path, owner: str) -> tuple[dict[str
212243
elif current is None:
213244
history.append(entry)
214245
active = []
246+
by_directory = {bot.directory: bot for bot in bots}
215247
for bot in sorted(bots, key=lambda item: item.name.casefold()):
216248
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"})
249+
team_members = [by_directory[bot.directory.parent / name].display_name for name in bot.team_member_names]
250+
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})
218251
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}
219252
return catalog, owner_data
220253

@@ -229,6 +262,7 @@ def main() -> int:
229262
root = arguments.root.resolve()
230263
try:
231264
bots = [validate_bot(platform, directory, smoke=arguments.smoke) for platform, directory in bot_directories(root)]
265+
validate_team_members(bots)
232266
check_governance(bots, root, arguments.owner)
233267
if arguments.generate:
234268
catalog, owners = generated_catalog(bots, root, arguments.owner)

tests/test_validate_bot.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,18 @@ 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 add_team(self, name: str, members: list[str]) -> None:
40+
destination = self.root / "bots" / "python" / name
41+
destination.mkdir()
42+
config = {
43+
"name": name,
44+
"version": "1.0",
45+
"authors": ["Test author"],
46+
"license": "Apache-2.0",
47+
"teamMembers": members,
48+
}
49+
(destination / f"{name}.json").write_text(json.dumps(config), encoding="utf-8")
50+
3951
def test_valid_submission_generates_an_active_catalog_entry(self) -> None:
4052
result = self.run_validator("--smoke", "--generate")
4153
self.assertEqual(0, result.returncode, result.stderr)
@@ -94,6 +106,26 @@ def test_registered_secondary_account_can_update_and_is_preserved(self) -> None:
94106
regenerated_owners = json.loads(owners_path.read_text(encoding="utf-8"))
95107
self.assertEqual(["primary", "secondary"], regenerated_owners["owners"][0]["accounts"])
96108

109+
def test_RBC004_IntegrationPositive_team_members_are_published_as_catalog_identities(self) -> None:
110+
self.add_bot("Nova")
111+
self.add_team("OrbitNova", ["Orbit", "Nova"])
112+
113+
result = self.run_validator("--generate")
114+
115+
self.assertEqual(0, result.returncode, result.stderr)
116+
catalog = json.loads((self.root / "bots" / "index.json").read_text(encoding="utf-8"))
117+
entries = {entry["name"]: entry for entry in catalog["bots"] if entry["status"] == "active"}
118+
self.assertEqual(["Orbit 1.0.2", "Nova 1.0.2"], entries["OrbitNova"]["teamMembers"])
119+
self.assertEqual([], entries["Orbit"]["teamMembers"])
120+
121+
def test_RBC004_IntegrationNegative_unknown_team_member_is_rejected(self) -> None:
122+
self.add_team("BrokenTeam", ["Orbit", "Missing"])
123+
124+
result = self.run_validator("--generate")
125+
126+
self.assertNotEqual(0, result.returncode)
127+
self.assertIn("unknown team member `Missing`", result.stderr)
128+
97129

98130
if __name__ == "__main__":
99131
unittest.main()

0 commit comments

Comments
 (0)