diff --git a/.github/PULL_REQUEST_TEMPLATE/bot-submission.md b/.github/PULL_REQUEST_TEMPLATE/bot-submission.md index e8b6ba7..a099c4b 100644 --- a/.github/PULL_REQUEST_TEMPLATE/bot-submission.md +++ b/.github/PULL_REQUEST_TEMPLATE/bot-submission.md @@ -1,7 +1,9 @@ ## Bot submission - [ ] I added source only under `bots///`. -- [ ] I included `.json`, `.sh`, and `.cmd`. +- [ ] For an individual bot, I included `.json`, `.sh`, `.cmd`, and source, with the `.sh` committed as executable (`100755`). +- [ ] For a TwinDuel team, the directory contains only `.json`, with exactly two active `name version` member identities and no launchers or source. - [ ] The config declares an allowed SPDX `license` and the source uses an official Tank Royale Bot API. - [ ] I ran `python scripts/validate_bot.py --root . --owner --smoke` successfully. +- [ ] For a source or member change to an existing entry, I increased its version. - [ ] I certify I can publish this source under the declared license. diff --git a/.github/workflows/publish-catalog.yml b/.github/workflows/publish-catalog.yml index 1f056f0..7cf54c6 100644 --- a/.github/workflows/publish-catalog.yml +++ b/.github/workflows/publish-catalog.yml @@ -27,6 +27,13 @@ jobs: with: distribution: temurin java-version: '25' + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + - uses: actions/setup-node@v4 + with: + node-version: '24' + - run: python -m pip install -r templates/python/StarterBot/requirements.txt - run: python scripts/validate_bot.py --root . --owner '${{ github.event.pull_request.user.login }}' --smoke --generate - run: | git config user.name 'github-actions[bot]' diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index de49b1b..02bc216 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -5,6 +5,8 @@ on: paths: - bots/** - scripts/** + - templates/** + - tests/** - .github/workflows/validate.yml permissions: @@ -13,6 +15,10 @@ permissions: jobs: validate: runs-on: ubuntu-latest + env: + RUMBLE_TEMPLATE_SMOKE: '1' + DOTNET_NOLOGO: '1' + DOTNET_CLI_TELEMETRY_OPTOUT: '1' steps: - uses: actions/checkout@v4 with: @@ -25,6 +31,13 @@ jobs: with: distribution: temurin java-version: '25' + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + - uses: actions/setup-node@v4 + with: + node-version: '24' + - run: python -m pip install -r templates/python/StarterBot/requirements.txt - run: python -m unittest discover -s tests -v - name: Reject hand-edited generated catalog files run: | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0e4aea1..ad3a886 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing a ranked bot -Submit source code only. Bots must use an official Tank Royale Bot API for Java, C#, Python, or TypeScript and run through the checked-in booter-convention scripts. Do not submit binary artifacts, generated dependency folders, custom protocol clients, process launchers, raw sockets, or code that writes outside its temporary directory. +Submit source code only. Bots must use an official Tank Royale Bot API for Java, C#, Python, or TypeScript and run through the checked-in booter-convention scripts. Dependencies are limited to the official API, its declared runtime dependencies and the platform's standard library. TypeScript bots explicitly declare the API's required `ws` runtime peer and commit their `package-lock.json`; unrelated bot libraries remain disallowed. Do not submit binary artifacts, generated dependency folders, custom protocol clients, process launchers, raw sockets, or code that writes outside its temporary directory. Each bot directory's `license` field is its license grant for the complete directory. By opening a pull request, you certify that you have the right to publish the submitted code under that SPDX license, in the spirit of the Developer Certificate of Origin. Include a full license file if it helps users, but it is optional. @@ -8,4 +8,6 @@ The first merged pull request for a bot name reserves that name for its submitti Use `python scripts/validate_bot.py --root . --owner --smoke` before opening a pull request. CI is authoritative. A green check does not replace moderator review, especially for a first submission. +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 ` branch. The validator requires Python 3.11+ and `sh`; Windows users can install Git for Windows 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. + Copyright complaints, unsafe code, impersonation, and disputes are handled under [GOVERNANCE.md](GOVERNANCE.md). Do not include secrets in source, bot configuration, issues, or pull-request text. diff --git a/README.md b/README.md index ae90aeb..191c9b6 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,12 @@ ## Submit a bot -1. Copy [the bot template](.github/PULL_REQUEST_TEMPLATE/bot-submission.md) into `bots///`. +1. Choose a [runnable bot template](templates/README.md) and follow its copy-and-rename instructions into `bots///`. 2. Include `.json`, `.sh`, `.cmd`, and source code that uses an official Tank Royale Bot API. 3. Declare one permitted SPDX license: `MIT`, `Apache-2.0`, `BSD-3-Clause`, or `GPL-3.0-or-later`. 4. Run `python scripts/validate_bot.py --root . --owner --smoke` before opening a pull request. -Read [CONTRIBUTING.md](CONTRIBUTING.md) for the full submission, ownership, licensing, and moderation rules. The current validator proves the source-run entry point starts; connecting the smoke check to a temporary Tank Royale server is the next hardening increment before public ranked submissions open. +Read [CONTRIBUTING.md](CONTRIBUTING.md) for the full submission, ownership, licensing, and moderation rules. The [template guide](templates/README.md#smoke-check-and-executable-scripts) explains smoke markers, runtime prerequisites and executable bits. JSON-only TwinDuel teams use the [team template](templates/README.md#json-only-twinduel-team). The validator's smoke check starts the source entry point; it does not establish a server connection or completed battle. Connecting the smoke check to a temporary Tank Royale server is the next hardening increment before public ranked submissions open. ## Catalog diff --git a/scripts/validate_bot.py b/scripts/validate_bot.py index 32c217e..dd7304a 100644 --- a/scripts/validate_bot.py +++ b/scripts/validate_bot.py @@ -11,6 +11,7 @@ import subprocess import sys import unicodedata +from urllib.parse import unquote from dataclasses import dataclass, replace from datetime import UTC, datetime from pathlib import Path @@ -21,10 +22,14 @@ "csharp": (".cs", "C#", "Robocode.TankRoyale.BotApi"), "java": (".java", "JVM", "dev.robocode.tankroyale.botapi"), "python": (".py", "Python", "robocode_tank_royale"), - "typescript": (".ts", "TypeScript", "@robocode/tank-royale"), + "typescript": (".ts", "TypeScript", "@robocode.dev/tank-royale-bot-api"), } FORBIDDEN_SUFFIXES = {".dll", ".exe", ".jar", ".so", ".dylib", ".pyc", ".class", ".zip", ".tar", ".gz"} FORBIDDEN_TOKENS = ("processbuilder", "runtime.exec", "subprocess", "os.system", "child_process", "system.diagnostics.process", "socket", "ctypes", "dllimport", "eval(") +TYPESCRIPT_API = "@robocode.dev/tank-royale-bot-api" +# The official API's NodeRuntimeAdapter requires its declared `ws` runtime peer. +# Review this boundary against the published API manifest when its dependency set changes. +TYPESCRIPT_RUNTIME_PEERS = {"ws"} LEET = str.maketrans({"0": "o", "1": "l", "3": "e", "4": "a", "5": "s", "7": "t", "8": "b", "9": "g", "@": "a", "$": "s", "!": "i", "|": "l"}) @@ -123,6 +128,8 @@ def validate_bot(platform_key: str, directory: Path, *, smoke: bool) -> Bot: 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}`") + if platform_key == "typescript": + validate_typescript_dependencies(directory) for suffix in (".sh", ".cmd"): script = directory / f"{directory.name}{suffix}" if not script.is_file(): @@ -138,6 +145,8 @@ def validate_bot(platform_key: str, directory: Path, *, smoke: bool) -> Bot: if api_token.lower() not in source_text: raise ValidationError(f"{directory}: source must reference the official Tank Royale {expected_platform} Bot API") for path in directory.rglob("*"): + if path.is_dir() and path.name in {"node_modules", ".venv", "bin", "obj"}: + raise ValidationError(f"{path}: generated dependency and build directories are not allowed") if not path.is_file(): continue if "__pycache__" in path.parts: @@ -153,6 +162,53 @@ def validate_bot(platform_key: str, directory: Path, *, smoke: bool) -> Bot: return bot +def validate_typescript_dependencies(directory: Path) -> None: + """Check reviewable npm declarations without installing or running package code.""" + package = read_json(directory / "package.json") + allowed = {TYPESCRIPT_API} | TYPESCRIPT_RUNTIME_PEERS + for section in ("dependencies", "devDependencies", "optionalDependencies", "peerDependencies"): + dependencies = package.get(section, {}) + if not isinstance(dependencies, dict): + raise ValidationError(f"{directory}: package.json `{section}` must be an object") + for name, version in dependencies.items(): + if name not in allowed: + raise ValidationError(f"{directory}: unrelated TypeScript dependency `{name}` is not allowed") + if not isinstance(version, str) or any(token in version for token in (":", "/", "\\")): + raise ValidationError(f"{directory}: `{name}` must use a registry version, not a path, URL or package alias") + dependencies = package.get("dependencies", {}) + if TYPESCRIPT_API not in dependencies: + raise ValidationError(f"{directory}: package.json dependencies must declare `{TYPESCRIPT_API}`") + lock = read_json(directory / "package-lock.json") + packages = lock.get("packages") + if not isinstance(packages, dict) or not isinstance(packages.get(""), dict): + raise ValidationError(f"{directory}: package-lock.json must contain npm package metadata") + for section in ("dependencies", "devDependencies", "optionalDependencies", "peerDependencies"): + if packages[""].get(section, {}) != package.get(section, {}): + raise ValidationError(f"{directory}: package-lock.json {section} do not match package.json; regenerate the lockfile") + api = packages.get(f"node_modules/{TYPESCRIPT_API}", {}) + peers = api.get("peerDependencies", {}) if isinstance(api, dict) else {} + if not isinstance(peers, dict): + raise ValidationError(f"{directory}: the official API's locked peerDependencies must be an object") + for peer in TYPESCRIPT_RUNTIME_PEERS: + if peer not in peers or peer not in dependencies or f"node_modules/{peer}" not in packages: + raise ValidationError(f"{directory}: the official TypeScript API requires its declared `{peer}` runtime peer in the manifest and lockfile") + for path in packages: + if not path: + continue + name = path.rsplit("node_modules/", 1)[-1] + if name not in allowed: + raise ValidationError(f"{directory}: unrelated locked TypeScript dependency `{name}` is not allowed") + entry = packages[path] + if not isinstance(entry, dict) or not isinstance(entry.get("resolved"), str): + raise ValidationError(f"{directory}: locked `{name}` is missing registry resolution metadata") + version = entry.get("version") + if not isinstance(version, str) or not re.fullmatch(r"\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?", version): + raise ValidationError(f"{directory}: locked `{name}` must identify a concrete registry version") + expected_url = f"https://registry.npmjs.org/{name}/-/{name.rsplit('/', 1)[-1]}-{version}.tgz" + if unquote(entry["resolved"]) != expected_url or not isinstance(entry.get("integrity"), str) or not entry["integrity"]: + raise ValidationError(f"{directory}: locked `{name}` must resolve to its official npm registry package with integrity metadata") + + 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] = {} diff --git a/templates/README.md b/templates/README.md new file mode 100644 index 0000000..ca9ea2c --- /dev/null +++ b/templates/README.md @@ -0,0 +1,88 @@ +# Start a ranked bot from a template + +The templates live outside `bots/`, so they do not reserve names or enter the catalog. Each individual template includes a bot configuration, Unix and Windows launchers, pinned official Bot API dependency and source entry point. The default bot turns its gun and fires when it scans an opponent. + +| Language | Copy this directory | Source file to rename | Prerequisites | +|---|---|---|---| +| Java | [java/StarterBot](java/StarterBot) | `src/StarterBot.java` | Java 25 and curl | +| C# | [csharp/StarterBot](csharp/StarterBot) | `src/StarterBot.cs` | .NET SDK 10 | +| Python | [python/StarterBot](python/StarterBot) | `src/StarterBot.py` | Python 3.11+ with the package from `requirements.txt` installed | +| TypeScript | [typescript/StarterBot](typescript/StarterBot) | `src/StarterBot.ts` | Node.js 24 and npm | + +The templates pin Bot API version 1.3.1 to match the current Rumble client's engine. Java and C# restore the official package from their launchers. Python uses the environment you prepare below. TypeScript uses Node's built-in TypeScript support and installs the API and its declared `ws` runtime peer with `npm ci` in a temporary run directory. Dependency caches and generated output stay outside the submitted bot directory. + +## Copy and rename + +Choose one language and a unique bot name using letters and digits, beginning with a letter. The example uses Java and `MyBot`; substitute your language and name throughout. + +On a Unix shell: + +```shell +mkdir -p bots/java +cp -R templates/java/StarterBot bots/java/MyBot +``` + +On PowerShell: + +```powershell +New-Item -ItemType Directory -Force bots/java | Out-Null +Copy-Item -Recurse templates/java/StarterBot bots/java/MyBot +``` + +In the copied directory, replace every `StarterBot` identifier in file contents with `MyBot`, including the class name, entry-point paths and smoke marker. Rename `StarterBot.json`, `StarterBot.sh`, `StarterBot.cmd` and the source file to use `MyBot`. For C#, also rename `StarterBot.csproj`. Keep the `src/` directory name unchanged. + +For TypeScript, retain both `package.json` and `package-lock.json`. The package name is private and need not match the bot name. If you intentionally change a dependency version, regenerate the lockfile with `npm install --package-lock-only --ignore-scripts --no-audit --no-fund --prefix bots/typescript/MyBot` and submit it for review. The allowed direct dependency set is the official API and its declared `ws` runtime peer; adding a different library is rejected. + +Edit the JSON `authors`, `description` and `license` to describe your bot. Start at version `1.0.0`. The directory, JSON filename and JSON `name` must match exactly, including case. The declared SPDX license applies to the complete submitted directory; retain a license you have the right to grant. + +For Python, create an environment outside the bot directory and install the declared API. On a Unix shell: + +```shell +python -m venv .venv +.venv/bin/python -m pip install -r bots/python/MyBot/requirements.txt +.venv/bin/python scripts/validate_bot.py --root . --owner YOUR_GITHUB_ACCOUNT --smoke +``` + +On PowerShell: + +```powershell +python -m venv .venv +.venv/Scripts/python.exe -m pip install -r bots/python/MyBot/requirements.txt +.venv/Scripts/python.exe scripts/validate_bot.py --root . --owner YOUR_GITHUB_ACCOUNT --smoke +``` + +The validator passes its own Python interpreter to Python launchers through `RUMBLE_PYTHON`. Running it from the environment above makes both the validation script and the bot use the installed API. + +## Smoke check and executable scripts + +Install Git and make `sh` available; on Windows, use Git for Windows with Git Bash's `sh` on `PATH`. Run the validator from the repository root: + +```shell +python scripts/validate_bot.py --root . --owner YOUR_GITHUB_ACCOUNT --smoke +``` + +The validator checks the whole catalog checkout, so its existing entries' runtimes must also be available. CI explicitly installs Python, Java 25, .NET 10 and Node.js 24. A template's smoke branch loads its API, prints `RUMBLE_SMOKE_READY MyBot` and exits successfully. The validator sets `RUMBLE_SMOKE=1` and allows 20 seconds per launcher. This checks the source entry point and dependency loading; it does not connect to a battle server. Test your strategy in the Tank Royale GUI before submitting it. + +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. + +Stage the bot and set the Unix launcher's executable bit in Git on every platform: + +```shell +git add bots/java/MyBot +git update-index --chmod=+x bots/java/MyBot/MyBot.sh +git ls-files --stage bots/java/MyBot/MyBot.sh +``` + +The last command must show mode `100755`. On Linux and macOS, also run `chmod +x bots/java/MyBot/MyBot.sh` before local validation. On Windows, the Git index mode is what the Linux CI checkout will receive. Keep generated dependencies and output outside `bots/`; submit no `node_modules`, virtual environment, `bin`, `obj`, JAR or DLL files. + +## Submit and update + +Use the [bot submission checklist](../.github/PULL_REQUEST_TEMPLATE/bot-submission.md) when opening the pull request. A moderator reviews the source; after merge, the publication workflow generates the catalog. Never edit `bots/index.json` or `bots/owners.json` in the PR. + +For the next version, edit your source and bump the JSON `version`, for example from `1.0.0` to `1.0.1`, then rerun validation and submit another PR. Changing source without a version bump is rejected. Verify that the generated catalog contains the new active identity after merge; older versions remain historical records. + +## JSON-only TwinDuel team + +Copy [team/StarterTeam/StarterTeam.json](team/StarterTeam/StarterTeam.json) into `bots///.json`. Replace `StarterTeam` with the team name, set your authors and license, and put the full active `name version` identity in each of the two `teamMembers` slots. Both slots may name the same individual bot. Members can come from different languages. + +The team directory contains only that JSON file: no `src/`, project file or launchers. Its member bots must already be in the catalog or in the same validated submission. A later member-version change requires an explicit update to `teamMembers` and a bump of the team's own version. Use the team-specific items in the submission checklist. diff --git a/templates/csharp/StarterBot/StarterBot.cmd b/templates/csharp/StarterBot/StarterBot.cmd new file mode 100644 index 0000000..dbc5595 --- /dev/null +++ b/templates/csharp/StarterBot/StarterBot.cmd @@ -0,0 +1,5 @@ +@echo off +setlocal +if "%RUMBLE_DOTNET_CACHE%"=="" set "RUMBLE_DOTNET_CACHE=%TEMP%\rumble-bots-dotnet" +dotnet run --project "%~dp0StarterBot.csproj" --artifacts-path "%RUMBLE_DOTNET_CACHE%\StarterBot-%RANDOM%-%RANDOM%" -- %* +exit /b %errorlevel% diff --git a/templates/csharp/StarterBot/StarterBot.csproj b/templates/csharp/StarterBot/StarterBot.csproj new file mode 100644 index 0000000..086fccf --- /dev/null +++ b/templates/csharp/StarterBot/StarterBot.csproj @@ -0,0 +1,14 @@ + + + Exe + net10.0 + enable + enable + false + false + + + + + + diff --git a/templates/csharp/StarterBot/StarterBot.json b/templates/csharp/StarterBot/StarterBot.json new file mode 100644 index 0000000..995f50e --- /dev/null +++ b/templates/csharp/StarterBot/StarterBot.json @@ -0,0 +1,10 @@ +{ + "name": "StarterBot", + "version": "1.0.0", + "authors": ["Your name"], + "description": "Turns its gun and fires at scanned opponents.", + "platform": "C#", + "programmingLang": "C# / .NET 10", + "gameTypes": ["1v1", "melee", "twinduel"], + "license": "Apache-2.0" +} diff --git a/templates/csharp/StarterBot/StarterBot.sh b/templates/csharp/StarterBot/StarterBot.sh new file mode 100755 index 0000000..6c67b9a --- /dev/null +++ b/templates/csharp/StarterBot/StarterBot.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env sh +set -eu +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +CACHE_ROOT="${RUMBLE_DOTNET_CACHE:-${TMPDIR:-/tmp}/rumble-bots-dotnet}" +mkdir -p "$CACHE_ROOT" +ARTIFACTS=$(mktemp -d "$CACHE_ROOT/StarterBot.XXXXXX") +exec dotnet run --project "$SCRIPT_DIR/StarterBot.csproj" --artifacts-path "$ARTIFACTS" -- "$@" diff --git a/templates/csharp/StarterBot/src/StarterBot.cs b/templates/csharp/StarterBot/src/StarterBot.cs new file mode 100644 index 0000000..265142d --- /dev/null +++ b/templates/csharp/StarterBot/src/StarterBot.cs @@ -0,0 +1,28 @@ +using Robocode.TankRoyale.BotApi; +using Robocode.TankRoyale.BotApi.Events; + +public class StarterBot : Bot +{ + public static void Main(string[] args) + { + if (Environment.GetEnvironmentVariable("RUMBLE_SMOKE") == "1") + { + Console.WriteLine("RUMBLE_SMOKE_READY StarterBot"); + return; + } + new StarterBot().Start(); + } + + public override void Run() + { + while (IsRunning) + { + TurnGunRight(10); + } + } + + public override void OnScannedBot(ScannedBotEvent evt) + { + Fire(1); + } +} diff --git a/templates/java/StarterBot/StarterBot.cmd b/templates/java/StarterBot/StarterBot.cmd new file mode 100644 index 0000000..800020e --- /dev/null +++ b/templates/java/StarterBot/StarterBot.cmd @@ -0,0 +1,13 @@ +@echo off +setlocal +set BOT_API_VERSION=1.3.1 +if "%RUMBLE_JAVA_CACHE%"=="" set "RUMBLE_JAVA_CACHE=%TEMP%\rumble-bots-java" +if not exist "%RUMBLE_JAVA_CACHE%" mkdir "%RUMBLE_JAVA_CACHE%" +set "JAR=%RUMBLE_JAVA_CACHE%\robocode-tankroyale-bot-api-%BOT_API_VERSION%.jar" +if not exist "%JAR%" ( + curl -fsSL -o "%JAR%.tmp" "https://repo.maven.apache.org/maven2/dev/robocode/tankroyale/robocode-tankroyale-bot-api/%BOT_API_VERSION%/robocode-tankroyale-bot-api-%BOT_API_VERSION%.jar" + if errorlevel 1 exit /b 1 + move /y "%JAR%.tmp" "%JAR%" >nul +) +java -cp "%JAR%" "%~dp0src\StarterBot.java" %* +exit /b %errorlevel% diff --git a/templates/java/StarterBot/StarterBot.json b/templates/java/StarterBot/StarterBot.json new file mode 100644 index 0000000..4977f3a --- /dev/null +++ b/templates/java/StarterBot/StarterBot.json @@ -0,0 +1,10 @@ +{ + "name": "StarterBot", + "version": "1.0.0", + "authors": ["Your name"], + "description": "Turns its gun and fires at scanned opponents.", + "platform": "JVM", + "programmingLang": "Java 25", + "gameTypes": ["1v1", "melee", "twinduel"], + "license": "Apache-2.0" +} diff --git a/templates/java/StarterBot/StarterBot.sh b/templates/java/StarterBot/StarterBot.sh new file mode 100755 index 0000000..bafb30b --- /dev/null +++ b/templates/java/StarterBot/StarterBot.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env sh +set -eu +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +BOT_API_VERSION=1.3.1 +CACHE_DIR="${RUMBLE_JAVA_CACHE:-${TMPDIR:-/tmp}/rumble-bots-java}" +JAR="$CACHE_DIR/robocode-tankroyale-bot-api-$BOT_API_VERSION.jar" +if [ ! -f "$JAR" ]; then + mkdir -p "$CACHE_DIR" + curl -fsSL -o "$JAR.$$.tmp" "https://repo.maven.apache.org/maven2/dev/robocode/tankroyale/robocode-tankroyale-bot-api/$BOT_API_VERSION/robocode-tankroyale-bot-api-$BOT_API_VERSION.jar" + mv "$JAR.$$.tmp" "$JAR" +fi +exec java -cp "$JAR" "$SCRIPT_DIR/src/StarterBot.java" "$@" diff --git a/templates/java/StarterBot/src/StarterBot.java b/templates/java/StarterBot/src/StarterBot.java new file mode 100644 index 0000000..b07a4f6 --- /dev/null +++ b/templates/java/StarterBot/src/StarterBot.java @@ -0,0 +1,24 @@ +import dev.robocode.tankroyale.botapi.Bot; +import dev.robocode.tankroyale.botapi.events.ScannedBotEvent; + +public class StarterBot extends Bot { + public static void main(final String[] args) { + if ("1".equals(System.getenv("RUMBLE_SMOKE"))) { + System.out.println("RUMBLE_SMOKE_READY StarterBot"); + return; + } + new StarterBot().start(); + } + + @Override + public void run() { + while (isRunning()) { + turnGunRight(10); + } + } + + @Override + public void onScannedBot(final ScannedBotEvent event) { + fire(1); + } +} diff --git a/templates/python/StarterBot/StarterBot.cmd b/templates/python/StarterBot/StarterBot.cmd new file mode 100644 index 0000000..db2de50 --- /dev/null +++ b/templates/python/StarterBot/StarterBot.cmd @@ -0,0 +1,5 @@ +@echo off +setlocal +if "%RUMBLE_PYTHON%"=="" set "RUMBLE_PYTHON=python" +"%RUMBLE_PYTHON%" "%~dp0src\StarterBot.py" %* +exit /b %errorlevel% diff --git a/templates/python/StarterBot/StarterBot.json b/templates/python/StarterBot/StarterBot.json new file mode 100644 index 0000000..4c2f5c9 --- /dev/null +++ b/templates/python/StarterBot/StarterBot.json @@ -0,0 +1,10 @@ +{ + "name": "StarterBot", + "version": "1.0.0", + "authors": ["Your name"], + "description": "Turns its gun and fires at scanned opponents.", + "platform": "Python", + "programmingLang": "Python 3.11+", + "gameTypes": ["1v1", "melee", "twinduel"], + "license": "Apache-2.0" +} diff --git a/templates/python/StarterBot/StarterBot.sh b/templates/python/StarterBot/StarterBot.sh new file mode 100755 index 0000000..26d7299 --- /dev/null +++ b/templates/python/StarterBot/StarterBot.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +set -eu +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +exec "${RUMBLE_PYTHON:-python3}" "$SCRIPT_DIR/src/StarterBot.py" "$@" diff --git a/templates/python/StarterBot/requirements.txt b/templates/python/StarterBot/requirements.txt new file mode 100644 index 0000000..c6fbe24 --- /dev/null +++ b/templates/python/StarterBot/requirements.txt @@ -0,0 +1 @@ +robocode-tank-royale==1.3.1 diff --git a/templates/python/StarterBot/src/StarterBot.py b/templates/python/StarterBot/src/StarterBot.py new file mode 100644 index 0000000..64cc9c6 --- /dev/null +++ b/templates/python/StarterBot/src/StarterBot.py @@ -0,0 +1,24 @@ +import os + +from robocode_tank_royale.bot_api.bot import Bot +from robocode_tank_royale.bot_api.events import ScannedBotEvent + + +class StarterBot(Bot): + def run(self) -> None: + while self.running: + self.turn_gun_right(10) + + def on_scanned_bot(self, event: ScannedBotEvent) -> None: + self.fire(1) + + +def main() -> None: + if os.environ.get("RUMBLE_SMOKE") == "1": + print("RUMBLE_SMOKE_READY StarterBot") + return + StarterBot().start() + + +if __name__ == "__main__": + main() diff --git a/templates/team/StarterTeam/StarterTeam.json b/templates/team/StarterTeam/StarterTeam.json new file mode 100644 index 0000000..2e93197 --- /dev/null +++ b/templates/team/StarterTeam/StarterTeam.json @@ -0,0 +1,7 @@ +{ + "name": "StarterTeam", + "version": "1.0.0", + "authors": ["Your name"], + "license": "Apache-2.0", + "teamMembers": ["MyBot 1.0.0", "MyBot 1.0.0"] +} diff --git a/templates/typescript/StarterBot/StarterBot.cmd b/templates/typescript/StarterBot/StarterBot.cmd new file mode 100644 index 0000000..4df13e5 --- /dev/null +++ b/templates/typescript/StarterBot/StarterBot.cmd @@ -0,0 +1,15 @@ +@echo off +setlocal +if "%RUMBLE_NODE_CACHE%"=="" set "RUMBLE_NODE_CACHE=%TEMP%\rumble-bots-node" +if not exist "%RUMBLE_NODE_CACHE%" mkdir "%RUMBLE_NODE_CACHE%" +for /f "delims=" %%D in ('node -e "console.log(require('node:fs').mkdtempSync(require('node:path').join(process.argv[1], 'StarterBot-')))" "%RUMBLE_NODE_CACHE%"') do set "RUN_DIR=%%D" +if "%RUN_DIR%"=="" exit /b 1 +copy /y "%~dp0package.json" "%RUN_DIR%\package.json" >nul +copy /y "%~dp0package-lock.json" "%RUN_DIR%\package-lock.json" >nul +copy /y "%~dp0StarterBot.json" "%RUN_DIR%\StarterBot.json" >nul +xcopy "%~dp0src\*" "%RUN_DIR%\src" /e /i /q /y >nul +if errorlevel 1 exit /b 1 +call npm ci --prefix "%RUN_DIR%" --ignore-scripts --no-audit --no-fund --omit=dev >nul +if errorlevel 1 exit /b 1 +node "%RUN_DIR%\src\StarterBot.ts" %* +exit /b %errorlevel% diff --git a/templates/typescript/StarterBot/StarterBot.json b/templates/typescript/StarterBot/StarterBot.json new file mode 100644 index 0000000..2485f8c --- /dev/null +++ b/templates/typescript/StarterBot/StarterBot.json @@ -0,0 +1,10 @@ +{ + "name": "StarterBot", + "version": "1.0.0", + "authors": ["Your name"], + "description": "Turns its gun and fires at scanned opponents.", + "platform": "TypeScript", + "programmingLang": "TypeScript / Node.js 24", + "gameTypes": ["1v1", "melee", "twinduel"], + "license": "Apache-2.0" +} diff --git a/templates/typescript/StarterBot/StarterBot.sh b/templates/typescript/StarterBot/StarterBot.sh new file mode 100755 index 0000000..305826c --- /dev/null +++ b/templates/typescript/StarterBot/StarterBot.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env sh +set -eu +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +RUN_ROOT="${RUMBLE_NODE_CACHE:-${TMPDIR:-/tmp}/rumble-bots-node}" +mkdir -p "$RUN_ROOT" +RUN_DIR=$(mktemp -d "$RUN_ROOT/StarterBot.XXXXXX") +cp "$SCRIPT_DIR/package.json" "$SCRIPT_DIR/package-lock.json" "$SCRIPT_DIR/StarterBot.json" "$RUN_DIR/" +cp -R "$SCRIPT_DIR/src" "$RUN_DIR/src" +npm ci --prefix "$RUN_DIR" --ignore-scripts --no-audit --no-fund --omit=dev >/dev/null +exec node "$RUN_DIR/src/StarterBot.ts" "$@" diff --git a/templates/typescript/StarterBot/package-lock.json b/templates/typescript/StarterBot/package-lock.json new file mode 100644 index 0000000..0f1f9d8 --- /dev/null +++ b/templates/typescript/StarterBot/package-lock.json @@ -0,0 +1,49 @@ +{ + "name": "rumble-starter-bot", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "rumble-starter-bot", + "dependencies": { + "@robocode.dev/tank-royale-bot-api": "1.3.1", + "ws": "8.22.0" + } + }, + "node_modules/@robocode.dev/tank-royale-bot-api": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@robocode.dev/tank-royale-bot-api/-/tank-royale-bot-api-1.3.1.tgz", + "integrity": "sha512-8CrSNhe7+zePvUcTWN/r5DYPqr63hzEulSZ/xJ2g1dKHDPLFq8dfWvF9dT35S3UwUs+zT2S+ewqRlw1TNt2h3A==", + "license": "Apache-2.0", + "peerDependencies": { + "ws": "^8.18.1" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/templates/typescript/StarterBot/package.json b/templates/typescript/StarterBot/package.json new file mode 100644 index 0000000..08da7f7 --- /dev/null +++ b/templates/typescript/StarterBot/package.json @@ -0,0 +1,9 @@ +{ + "name": "rumble-starter-bot", + "private": true, + "type": "module", + "dependencies": { + "@robocode.dev/tank-royale-bot-api": "1.3.1", + "ws": "8.22.0" + } +} diff --git a/templates/typescript/StarterBot/src/StarterBot.ts b/templates/typescript/StarterBot/src/StarterBot.ts new file mode 100644 index 0000000..6c684bf --- /dev/null +++ b/templates/typescript/StarterBot/src/StarterBot.ts @@ -0,0 +1,19 @@ +import { Bot, type ScannedBotEvent } from "@robocode.dev/tank-royale-bot-api"; + +class StarterBot extends Bot { + override run() { + while (this.isRunning()) { + this.turnGunRight(10); + } + } + + override onScannedBot(event: ScannedBotEvent) { + this.fire(1); + } +} + +if (process.env.RUMBLE_SMOKE === "1") { + console.log("RUMBLE_SMOKE_READY StarterBot"); +} else { + new StarterBot().start(); +} diff --git a/tests/test_templates.py b/tests/test_templates.py new file mode 100644 index 0000000..ca35c3d --- /dev/null +++ b/tests/test_templates.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +REPOSITORY = Path(__file__).resolve().parents[1] +VALIDATOR = REPOSITORY / "scripts" / "validate_bot.py" +sys.path.insert(0, str(REPOSITORY / "scripts")) +from validate_bot import tree_hash # noqa: E402 + +LANGUAGES = ("java", "csharp", "python", "typescript") + + +def copy_template(root: Path, language: str, name: str) -> Path: + """Exercise the documented copy-and-rename steps in a separate submission tree.""" + destination = root / "bots" / language / name + 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") + if "StarterBot" in path.name: + path.rename(path.with_name(path.name.replace("StarterBot", name))) + return destination + + +class TemplateSubmissionTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory(prefix="rumble templates ") + self.root = Path(self.temporary.name) + (self.root / "bots").mkdir() + (self.root / "bots" / "banned.json").write_text('{"bannedOwners":[],"disqualifiedBots":[]}', encoding="utf-8") + + def tearDown(self) -> None: + self.temporary.cleanup() + + def validate(self, *arguments: str) -> subprocess.CompletedProcess[str]: + return subprocess.run([sys.executable, str(VALIDATOR), "--root", str(self.root), + "--owner", "template-author", *arguments], text=True, capture_output=True, check=False) + + def test_RBC001_IntegrationPositive_renamed_templates_generate_real_catalog_entries(self) -> None: + for language in LANGUAGES: + with self.subTest(language=language): + name = language.capitalize() + "Scout" + copy_template(self.root, language, name) + result = self.validate("--generate") + self.assertEqual(0, result.returncode, result.stderr) + catalog = json.loads((self.root / "bots" / "index.json").read_text()) + self.assertEqual({language.capitalize() + "Scout" for language in LANGUAGES}, + {entry["name"] for entry in catalog["bots"]}) + self.assertTrue(all(entry["owner"] == "template-author" for entry in catalog["bots"])) + + @unittest.skipUnless(os.environ.get("RUMBLE_TEMPLATE_SMOKE") == "1", "set RUMBLE_TEMPLATE_SMOKE=1 with all template runtimes installed") + def test_RBC001_IntegrationPositive_renamed_launchers_smoke_without_changing_source_hash(self) -> None: + directories = [copy_template(self.root, language, language.capitalize() + "Scout") for language in LANGUAGES] + before = {directory: tree_hash(directory) for directory in directories} + result = self.validate("--smoke", "--generate") + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual(before, {directory: tree_hash(directory) for directory in directories}) + + def test_RBC004_IntegrationPositive_team_template_needs_only_json_and_resolves_twin_slots(self) -> None: + copy_template(self.root, "python", "MyBot") + 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") + self.assertEqual(["MyTwins.json"], [path.name for path in team.iterdir()]) + result = self.validate("--generate") + self.assertEqual(0, result.returncode, result.stderr) + catalog = json.loads((self.root / "bots" / "index.json").read_text()) + entry = next(entry for entry in catalog["bots"] if entry["name"] == "MyTwins") + self.assertEqual(["MyBot 1.0.0", "MyBot 1.0.0"], entry["teamMembers"]) + + def test_RBC003_IntegrationPositive_template_version_update_supersedes_first_entry(self) -> None: + directory = copy_template(self.root, "python", "MyBot") + self.assertEqual(0, self.validate("--generate").returncode) + config_path = directory / "MyBot.json" + config = json.loads(config_path.read_text()) + config["version"] = "1.0.1" + config_path.write_text(json.dumps(config), encoding="utf-8") + result = self.validate("--generate") + self.assertEqual(0, result.returncode, result.stderr) + catalog = json.loads((self.root / "bots" / "index.json").read_text()) + self.assertEqual([("1.0.0", "superseded"), ("1.0.1", "active")], + [(entry["version"], entry["status"]) for entry in catalog["bots"]]) + + def test_RBC006_IntegrationPositive_official_api_and_its_declared_runtime_peer_are_accepted(self) -> None: + copy_template(self.root, "typescript", "MyBot") + result = self.validate() + self.assertEqual(0, result.returncode, result.stderr) + + def test_RBC006_IntegrationNegative_missing_lockfile_is_rejected(self) -> None: + directory = copy_template(self.root, "typescript", "MyBot") + (directory / "package-lock.json").unlink() + result = self.validate() + self.assertNotEqual(0, result.returncode) + self.assertIn("package-lock.json", result.stderr) + + def test_RBC006_IntegrationNegative_an_unrelated_library_is_rejected_even_if_claimed_as_a_peer(self) -> None: + directory = copy_template(self.root, "typescript", "MyBot") + package = json.loads((directory / "package.json").read_text()) + package["dependencies"]["unrelated-library"] = "1.0.0" + (directory / "package.json").write_text(json.dumps(package), encoding="utf-8") + lock = json.loads((directory / "package-lock.json").read_text()) + lock["packages"]["node_modules/@robocode.dev/tank-royale-bot-api"]["peerDependencies"]["unrelated-library"] = "1.0.0" + (directory / "package-lock.json").write_text(json.dumps(lock), encoding="utf-8") + result = self.validate() + self.assertNotEqual(0, result.returncode) + self.assertIn("unrelated TypeScript dependency `unrelated-library`", result.stderr) + + def test_RBC006_IntegrationNegative_an_unrelated_locked_package_is_rejected(self) -> None: + directory = copy_template(self.root, "typescript", "MyBot") + lock = json.loads((directory / "package-lock.json").read_text()) + lock["packages"]["node_modules/unrelated-library"] = {"version": "1.0.0"} + (directory / "package-lock.json").write_text(json.dumps(lock), encoding="utf-8") + result = self.validate() + self.assertNotEqual(0, result.returncode) + self.assertIn("unrelated locked TypeScript dependency", result.stderr) + + def test_RBC006_IntegrationNegative_missing_required_runtime_peer_is_rejected(self) -> None: + directory = copy_template(self.root, "typescript", "MyBot") + package = json.loads((directory / "package.json").read_text()) + del package["dependencies"]["ws"] + (directory / "package.json").write_text(json.dumps(package), encoding="utf-8") + lock = json.loads((directory / "package-lock.json").read_text()) + del lock["packages"][""]["dependencies"]["ws"] + (directory / "package-lock.json").write_text(json.dumps(lock), encoding="utf-8") + result = self.validate() + self.assertNotEqual(0, result.returncode) + self.assertIn("requires its declared `ws` runtime peer", result.stderr) + + def test_RBC006_IntegrationNegative_generated_dependencies_are_not_submitted(self) -> None: + directory = copy_template(self.root, "typescript", "MyBot") + (directory / "node_modules").mkdir() + result = self.validate() + self.assertNotEqual(0, result.returncode) + self.assertIn("generated dependency and build directories", result.stderr) + + def test_RBC006_IntegrationNegative_an_alias_cannot_replace_the_official_api(self) -> None: + directory = copy_template(self.root, "typescript", "MyBot") + package = json.loads((directory / "package.json").read_text()) + package["dependencies"]["@robocode.dev/tank-royale-bot-api"] = "npm:unrelated-library@1.0.0" + (directory / "package.json").write_text(json.dumps(package), encoding="utf-8") + result = self.validate() + self.assertNotEqual(0, result.returncode) + self.assertIn("registry version", result.stderr) + + def test_RBC006_IntegrationNegative_unofficial_lock_resolution_is_rejected(self) -> None: + directory = copy_template(self.root, "typescript", "MyBot") + lock = json.loads((directory / "package-lock.json").read_text()) + lock["packages"]["node_modules/ws"]["resolved"] = "https://example.com/ws.tgz" + (directory / "package-lock.json").write_text(json.dumps(lock), encoding="utf-8") + result = self.validate() + self.assertNotEqual(0, result.returncode) + self.assertIn("official npm registry package", result.stderr) + + def test_RBC006_IntegrationNegative_malformed_peer_metadata_has_a_diagnostic(self) -> None: + directory = copy_template(self.root, "typescript", "MyBot") + lock = json.loads((directory / "package-lock.json").read_text()) + lock["packages"]["node_modules/@robocode.dev/tank-royale-bot-api"]["peerDependencies"] = None + (directory / "package-lock.json").write_text(json.dumps(lock), encoding="utf-8") + result = self.validate() + self.assertNotEqual(0, result.returncode) + self.assertIn("locked peerDependencies must be an object", result.stderr) + self.assertNotIn("Traceback", result.stderr)