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
4 changes: 4 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Bot sources are stored and checked out with Unix line endings on every system, so a bot has the same content hash everywhere.
* text=auto eol=lf
*.sh text eol=lf
*.cmd text eol=lf
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Each bot directory's `license` field is its license grant for the complete direc

The first merged pull request for a bot name reserves that name for its submitting forge account. Only that account, or an account later registered in `bots/owners.json` by an already registered account, may submit later versions. A source change requires a version bump; only the latest version remains active. Each owner has five active entries by default, including a TwinDuel team.

Use `python scripts/validate_bot.py --root . --owner <your-forge-account> --smoke` before opening a pull request. CI is authoritative. A bot-only PR can be merged automatically after source and security checks and a completed one-round battle. Keep launcher scripts faithful to the published starter; modified launchers, team metadata, security uncertainty and unrelated file changes go to the Tank Royale Rumble moderators. If the bot does not run, the PR receives a changes-requested review explaining why; push a fix to rerun moderation.
Use `python scripts/validate_bot.py --root . --owner <your-forge-account> --smoke` before opening a pull request. CI is authoritative. A bot-only PR can be merged automatically after source and security checks and a completed one-round battle. Files must have Unix line endings (LF). The repository stores them that way, and the validator rejects Windows line endings (CRLF) and names the file. Keep launcher scripts faithful to the published starter; modified launchers, team metadata, security uncertainty and unrelated file changes go to the Tank Royale Rumble moderators. If the bot does not run, the PR receives a changes-requested review explaining why; push a fix to rerun moderation.

Start from the [runnable templates and copy-and-rename instructions](templates/README.md). They include pinned dependencies, both launchers and the required `RUMBLE_SMOKE_READY <BotName>` branch. The validator requires Python 3.11+ and `sh`; Windows users can install [Git for Windows](https://gitforwindows.org/) and make its shell available. It sets `RUMBLE_SMOKE=1`, invokes each individual bot's `.sh` launcher and requires a successful exit with the marker within 20 seconds. This verifies startup rather than a server handshake. Commit each `.sh` with executable mode `100755`, using `git update-index --chmod=+x` after staging it. Team entries contain JSON only and skip individual-bot launcher checks.

Expand Down
12 changes: 12 additions & 0 deletions scripts/validate_bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,17 @@ def bot_directories(root: Path) -> list[tuple[str, Path]]:
return result


def check_line_endings(directory: Path) -> None:
"""Reject Windows line endings: the catalog holds Unix line endings, and a launcher script with CRLF cannot run under sh."""
for path in sorted(directory.rglob("*")):
if not path.is_file() or "__pycache__" in path.parts:
continue
data = path.read_bytes()
if b"\0" not in data and b"\r\n" in data:
relative = path.relative_to(directory).as_posix()
raise ValidationError(f"{directory}: {relative} has Windows line endings (CRLF); convert it to Unix line endings (LF)")


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")
Expand All @@ -116,6 +127,7 @@ def validate_bot(platform_key: str, directory: Path, *, smoke: bool) -> Bot:
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))}")
check_line_endings(directory)
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):
Expand Down
2 changes: 2 additions & 0 deletions templates/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ The validator checks the whole catalog checkout, so its existing entries' runtim

Initial Java and NuGet downloads require network access. If a first download exceeds the smoke timeout, run the launcher once with `RUMBLE_SMOKE=1` to populate its external cache, then rerun validation. Preserve the smoke branch when editing your strategy; normal battle execution leaves `RUMBLE_SMOKE` unset.

Use Unix line endings (LF) in every file of the bot. The repository sets this for Git, so staging with Git normally takes care of it, but a file uploaded through the web page or written by a tool is not changed, and the validator rejects a file with Windows line endings (CRLF). A launcher script with them also cannot run under `sh`.

Stage the bot and set the Unix launcher's executable bit in Git on every platform:

```shell
Expand Down
5 changes: 3 additions & 2 deletions tests/test_templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ def copy_template(root: Path, language: str, name: str) -> Path:
shutil.copytree(REPOSITORY / "templates" / language / "StarterBot", destination)
for path in list(destination.rglob("*")):
if path.is_file():
path.write_text(path.read_text(encoding="utf-8").replace("StarterBot", name), encoding="utf-8")
# Bytes, not text: writing text on Windows would turn every line ending into a Windows line ending.
path.write_bytes(path.read_bytes().replace(b"StarterBot", name.encode("utf-8")))
if "StarterBot" in path.name:
path.rename(path.with_name(path.name.replace("StarterBot", name)))
return destination
Expand Down Expand Up @@ -68,7 +69,7 @@ def test_RBC004_IntegrationPositive_team_template_needs_only_json_and_resolves_t
team = self.root / "bots" / "python" / "MyTwins"
team.mkdir()
source = REPOSITORY / "templates" / "team" / "StarterTeam" / "StarterTeam.json"
(team / "MyTwins.json").write_text(source.read_text().replace("StarterTeam", "MyTwins"), encoding="utf-8")
(team / "MyTwins.json").write_bytes(source.read_bytes().replace(b"StarterTeam", b"MyTwins"))
self.assertEqual(["MyTwins.json"], [path.name for path in team.iterdir()])
result = self.validate("--generate")
self.assertEqual(0, result.returncode, result.stderr)
Expand Down
27 changes: 26 additions & 1 deletion tests/test_validate_bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ def test_invalid_license_is_rejected(self) -> None:
def test_source_change_without_version_increase_is_rejected(self) -> None:
self.assertEqual(0, self.run_validator("--generate").returncode)
source_path = self.root / "bots" / "python" / "Orbit" / "src" / "Orbit.py"
source_path.write_text(source_path.read_text(encoding="utf-8") + "\n# changed\n", encoding="utf-8")
source_path.write_bytes(source_path.read_bytes() + b"\n# changed\n") # bytes: write_text would give Windows line endings
result = self.run_validator()
self.assertNotEqual(0, result.returncode)
self.assertIn("without increasing its version", result.stderr)
Expand Down Expand Up @@ -262,6 +262,31 @@ def test_non_executable_boot_script_is_rejected(self) -> None:
self.assertNotEqual(0, result.returncode)
self.assertIn("executable bit", result.stderr)

def test_windows_line_endings_in_a_launcher_are_rejected_with_a_plain_message(self) -> None:
script = self.root / "bots" / "python" / "Orbit" / "Orbit.sh"
script.write_bytes(script.read_bytes().replace(b"\n", b"\r\n"))
result = self.run_validator()
self.assertNotEqual(0, result.returncode)
self.assertIn("Orbit.sh has Windows line endings (CRLF)", result.stderr)
self.assertIn("convert it to Unix line endings (LF)", result.stderr)

def test_windows_line_endings_in_a_source_file_or_the_configuration_are_rejected(self) -> None:
for relative in ("src/Orbit.py", "Orbit.json"):
with self.subTest(relative):
path = self.root / "bots" / "python" / "Orbit" / relative
original = path.read_bytes()
path.write_bytes(original.replace(b"\n", b"\r\n") if b"\n" in original else original + b"\r\n")
result = self.run_validator()
self.assertNotEqual(0, result.returncode)
self.assertIn(f"{relative} has Windows line endings", result.stderr)
path.write_bytes(original)

def test_unix_line_endings_are_accepted(self) -> None:
for path in (self.root / "bots" / "python" / "Orbit").rglob("*"):
if path.is_file():
self.assertNotIn(b"\r\n", path.read_bytes(), str(path))
self.assertEqual(0, self.run_validator().returncode)

def test_RBC004_IntegrationNegative_duplicate_bot_names_across_platforms_are_rejected(self) -> None:
self.add_java_bot("Orbit")

Expand Down
Loading