diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml new file mode 100644 index 0000000..485ac27 --- /dev/null +++ b/.github/workflows/python.yml @@ -0,0 +1,91 @@ +name: Python Bindings + +on: + push: + branches: [main, master] + pull_request: + +jobs: + build: + name: Build / ${{ matrix.name }} + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + name: Linux + - os: macos-latest + name: macOS + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "latest" + + - name: Build wheel + run: | + cd python + uv build --wheel + + - name: Upload wheel + uses: actions/upload-artifact@v4 + with: + name: wheel-${{ matrix.os }} + path: python/dist/*.whl + + test: + name: Test / ${{ matrix.name }} + needs: build + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + name: Linux + - os: macos-latest + name: macOS + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "latest" + + - name: Download wheel + uses: actions/download-artifact@v4 + with: + name: wheel-${{ matrix.os }} + path: python/dist + + - name: Install dependencies + run: | + cd python + uv venv + uv pip install dist/*.whl pytest numpy + + - name: Run tests + run: | + cd python + source .venv/bin/activate + pytest tests/ -v diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f6eda14..ac2b3c3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,14 +1,13 @@ -name: Tests +name: C++ Core on: push: - branches: [main, master, develop] + branches: [main, master] pull_request: - branches: [main, master, develop] jobs: - test: - name: Test on ${{ matrix.os }} + build-and-test: + name: ${{ matrix.name }} runs-on: ${{ matrix.os }} strategy: @@ -18,69 +17,24 @@ jobs: - os: ubuntu-latest name: Linux - os: macos-latest - name: macOS ARM - - os: windows-latest - name: Windows + name: macOS steps: - - name: Checkout code + - name: Checkout uses: actions/checkout@v4 - - name: Setup CMake - uses: jwlawson/actions-setup-cmake@v2 - with: - cmake-version: "3.20" - - - name: Setup MSVC environment (Windows) - if: runner.os == 'Windows' - uses: ilammy/msvc-dev-cmd@v1 - - - name: Install Ninja (Windows) - if: runner.os == 'Windows' - uses: seanmiddleditch/gha-setup-ninja@v3 - - - name: Configure CMake (Windows) - if: runner.os == 'Windows' - run: | - New-Item -ItemType Directory -Force -Path build - cd build - # Remove MinGW from PATH to force MSVC detection - $env:Path = ($env:Path -split ';' | Where-Object { $_ -notlike '*mingw*' }) -join ';' - cmake .. -G "Ninja" -DCMAKE_BUILD_TYPE=Release -DCMAKE_C_COMPILER=cl -DCMAKE_CXX_COMPILER=cl - shell: pwsh - - - name: Configure CMake (Unix) - if: runner.os != 'Windows' + - name: Configure run: | mkdir -p build cd build cmake .. - shell: bash - - name: Build tests (Windows) - if: runner.os == 'Windows' - run: | - cd build - cmake --build . --target RocketSimTests -j4 - shell: pwsh - - - name: Build tests (Unix) - if: runner.os != 'Windows' + - name: Build run: | cd build cmake --build . --target RocketSimTests -j$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4) - shell: bash - - - name: Run tests (Windows) - if: runner.os == 'Windows' - run: | - cd build - ctest --output-on-failure - shell: pwsh - - name: Run tests (Unix) - if: runner.os != 'Windows' + - name: Test run: | cd build ctest --output-on-failure - shell: bash diff --git a/.gitignore b/.gitignore index ee37f49..3067346 100644 --- a/.gitignore +++ b/.gitignore @@ -50,4 +50,14 @@ CMakeSettings.json ___* # MacOS -.DS_Store \ No newline at end of file +.DS_Store + +# python +python/build/ +python/dist/ +python/RocketSim.egg-info/ +python/.scikit-build/ +python/.venv/ +python/.pytest_cache/ +__pycache__/ +*.pyc \ No newline at end of file diff --git a/README.md b/README.md index a86c6a7..9504865 100644 --- a/README.md +++ b/README.md @@ -1,68 +1,63 @@ -![image](https://user-images.githubusercontent.com/36944229/219303954-7267bce1-b7c5-4f15-881c-b9545512e65b.png) +# RocketSimPy -**A C++ library for simulating Rocket League games at maximum efficiency** +**A modernized fork of [RocketSim](https://github.com/ZealanL/RocketSim) with official Python bindings** -RocketSim is a complete simulation of Rocket League's gameplay logic and physics that is completely standalone. -RocketSim supports the game modes: Soccar, Hoops, Heatseeker, and Snowday. +A C++ library for simulating Rocket League games at maximum efficiency, now with first-class Python support via [nanobind](https://github.com/wjakob/nanobind). -# Speed -RocketSim is designed to run extremely fast, even when complex collisions and suspension calculations are happening every tick. -On an average PC running a single thread of RocketSim with two cars, RocketSim can simulate around 20 minutes of game time every second. -This means that with 12 threads running RocketSim, you can simulate around 10 days of game time every minute! +## What's Different -# Accuracy -RocketSim is not a perfectly accurate replication of Rocket League, but is close enough for most applications (such as training ML bots). -RocketSim is accurate enough to: -- *Train machine learning bots to SSL level (and probably beyond)* -- *Simulate different shots on the ball at different angles to find the best input combination* -- *Simulate air control to find the optimal orientation input* -- *Simulate pinches* +This fork builds on ZealanL's original RocketSim and takes inspiration from [mtheall's Python bindings](https://github.com/mtheall/RocketSim): -However, the tiny errors will accumulate over time, so RocketSim is best suited for simulation with consistent feedback. +- **nanobind bindings** — Faster, cleaner Python bindings (not pybind11) +- **Full test coverage** — C++ and Python test suites with CI +- **RLGym compatible** — Drop-in replacement for [rlgym](https://rlgym.org/) environments +- **Modern build system** — scikit-build-core + uv for Python packaging -## Installation -- Clone this repo and build it -- Use https://github.com/ZealanL/RLArenaCollisionDumper to dump all of Rocket League's arena collision meshes -- Move those assets into RocketSim's executing directory +## Quick Start -## Documentation -Documentation is available at: https://zealanl.github.io/RocketSimDocs/ +```bash +# Build and install Python bindings +cd python +uv build --wheel +uv pip install dist/*.whl +``` + +```python +import RocketSim as rs -## Bindings -If you don't want to work in C++, here are some (unofficial) bindings written in other languages: -- **Python**: https://github.com/mtheall/RocketSim by `mtheall` -- **Python**: https://github.com/uservar/pyrocketsim by `uservar` -- **Rust**: https://github.com/VirxEC/rocketsim-rs by `VirxEC` +rs.init("collision_meshes") +arena = rs.Arena(rs.GameMode.SOCCAR) +car = arena.add_car(rs.Team.BLUE, rs.CAR_CONFIG_OCTANE) +arena.step(100) +``` -Official Python bindings are currently in the works. +## Speed -## Performance Details -RocketSim already heavily outperforms the speed of Rocket League's physics tick step without optimization. +RocketSim simulates ~20 minutes of game time per second on a single thread. With 12 threads, that's ~10 days of game time per minute. -Version performance comparison: -``` -OS: Windows 10 (Process Priority = Normal) -CPU: Intel i5-11400 @ 2.60GHz -Ram Speed: 3200MZ -Compiler: MSVC 14.16 -================================= -Arena: Default (Soccar) -Cars: 2 on each team (2v2) -Inputs: Randomly pre-generated, changed every 2-60 ticks for each car -================================= -Single-thread performance (calculated using average CPU cycles per tick on the RocketSim thread) (1M ticks simulated): -v1.0.0 = 30,334tps -v1.1.0 = 48,191tps -v1.2.0 = 50,763tps -v2.0.0 = ~50,000tps -v2.1.0 = 114,481tps -``` +## Accuracy + +Accurate enough to train ML bots to SSL level, simulate shots, air control, and pinches. Small errors accumulate over time — best suited for simulation with consistent feedback. -## Issues & PRs -Feel free to make issues and pull requests if you encounter any issues! +## Installation + +1. Clone this repo +2. Dump arena collision meshes using [RLArenaCollisionDumper](https://github.com/ZealanL/RLArenaCollisionDumper) +3. Build: `mkdir build && cd build && cmake .. && make` + +For Python bindings, see [python/README.md](python/README.md). + +## Documentation + +- Original docs: [zealanl.github.io/RocketSimDocs](https://zealanl.github.io/RocketSimDocs/) +- Python API: [python/README.md](python/README.md) -You can also contact me on Discord if you have questions: `Zealan#5987` +## Credits + +- [ZealanL/RocketSim](https://github.com/ZealanL/RocketSim) — Original implementation +- [mtheall/RocketSim](https://github.com/mtheall/RocketSim) — Python bindings inspiration +- [RLGym](https://rlgym.org/) — Target compatibility ## Legal Notice -RocketSim was written to replicate Rocket League's game logic, but does not actually contain any code from the game. -To Epic Games/Psyonix: If any of you guys have an issue with this, let me know on Discord and we can resolve it. + +RocketSim replicates Rocket League's game logic but contains no code from the game. diff --git a/python/CMakeLists.txt b/python/CMakeLists.txt new file mode 100644 index 0000000..d7994c0 --- /dev/null +++ b/python/CMakeLists.txt @@ -0,0 +1,61 @@ +cmake_minimum_required(VERSION 3.15...3.27) +project(RocketSimPy LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +# Find Python and nanobind +find_package(Python 3.8 COMPONENTS Interpreter Development.Module REQUIRED) + +# Detect the installed nanobind package and import it into CMake +execute_process( + COMMAND "${Python_EXECUTABLE}" -m nanobind --cmake_dir + OUTPUT_STRIP_TRAILING_WHITESPACE OUTPUT_VARIABLE nanobind_ROOT) +find_package(nanobind CONFIG REQUIRED) + +# Collect RocketSim source files +file(GLOB_RECURSE ROCKETSIM_SOURCES + "${CMAKE_CURRENT_SOURCE_DIR}/../src/*.cpp" +) + +# Collect all Bullet sources +file(GLOB_RECURSE BULLET_SOURCES + "${CMAKE_CURRENT_SOURCE_DIR}/../libsrc/bullet3-3.24/*.cpp" +) + +# Create the nanobind module +nanobind_add_module( + RocketSim + STABLE_ABI + NB_STATIC + src/bindings.cpp + ${ROCKETSIM_SOURCES} + ${BULLET_SOURCES} +) + +# Include directories +target_include_directories(RocketSim PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/../src" + "${CMAKE_CURRENT_SOURCE_DIR}/../libsrc/bullet3-3.24" +) + +# Suppress warnings from Bullet library (third-party code) +if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang|AppleClang") + target_compile_options(RocketSim PRIVATE + -Wno-c99-extensions + -Wno-gnu-statement-expression-from-macro-expansion + -Wno-unused-parameter + -Wno-sign-compare + -Wno-unused-variable + -Wno-unused-function + -Wno-unused-but-set-variable + -Wno-reorder-ctor + -Wno-deprecated-copy + -Wno-delete-non-abstract-non-virtual-dtor + -Wno-return-type + ) +endif() + +# Install the module +install(TARGETS RocketSim LIBRARY DESTINATION .) + diff --git a/python/README.md b/python/README.md new file mode 100644 index 0000000..852854d --- /dev/null +++ b/python/README.md @@ -0,0 +1,110 @@ +# RocketSim Python Bindings + +Python bindings for [RocketSim](https://github.com/ZealanL/RocketSim) built with [nanobind](https://github.com/wjakob/nanobind) for maximum performance. + +## Installation + +```bash +cd python +uv build --wheel +uv pip install dist/*.whl +``` + +## RLGym Compatibility + +These bindings target full compatibility with [RLGym](https://rlgym.org/). The API matches what rlgym expects from RocketSim >=2.1. + +## Quick Start + +```python +import RocketSim as rs + +rs.init("path/to/collision_meshes") + +arena = rs.Arena(rs.GameMode.SOCCAR) +car = arena.add_car(rs.Team.BLUE, rs.CAR_CONFIG_OCTANE) + +controls = rs.CarControls() +controls.throttle = 1.0 +controls.boost = True +car.set_controls(controls) + +arena.step(100) + +state = car.get_state() +print(f"Position: {state.pos.as_numpy()}") +``` + +## API Reference + +### Core + +```python +rs.init(path) # Initialize with collision meshes +rs.Arena(game_mode, tick_rate) # Create simulation arena +rs.GameMode.SOCCAR / .HOOPS / .HEATSEEKER / .SNOWDAY / .DROPSHOT +rs.Team.BLUE / .ORANGE +``` + +### Arena + +```python +arena.step(ticks) # Advance simulation +arena.clone() # Deep copy +arena.add_car(team, config) # Add car, returns Car +arena.remove_car(car) +arena.get_cars() # List of cars +arena.get_boost_pads() # List of boost pads +arena.ball # Ball object +arena.tick_count, .tick_rate +``` + +### Car + +```python +car.id, car.team +car.get_state() / .set_state(state) +car.get_controls() / .set_controls(controls) +``` + +### CarState + +```python +state.pos, .vel, .ang_vel # Vec +state.rot_mat # RotMat +state.boost # float [0, 100] +state.is_on_ground, .is_supersonic, .is_demoed +state.has_jumped, .has_double_jumped, .has_flipped +``` + +### CarControls + +```python +controls.throttle, .steer # float [-1, 1] +controls.pitch, .yaw, .roll # float [-1, 1] +controls.boost, .jump, .handbrake # bool +``` + +### Vec / RotMat + +```python +vec = rs.Vec(x, y, z) +vec.as_numpy() # np.array([x, y, z]) + +rot = rs.RotMat() +rot.forward, .right, .up # Vec +rot.as_numpy() # np.array((3, 3)) +``` + +### Car Configs + +```python +rs.CAR_CONFIG_OCTANE, rs.CAR_CONFIG_DOMINUS +rs.CAR_CONFIG_PLANK, rs.CAR_CONFIG_BREAKOUT +rs.CAR_CONFIG_HYBRID, rs.CAR_CONFIG_MERC +``` + +## Credits + +- [ZealanL/RocketSim](https://github.com/ZealanL/RocketSim) — Original C++ implementation +- [mtheall/RocketSim](https://github.com/mtheall/RocketSim) — Python bindings inspiration diff --git a/python/pyproject.toml b/python/pyproject.toml new file mode 100644 index 0000000..178e09f --- /dev/null +++ b/python/pyproject.toml @@ -0,0 +1,53 @@ +[build-system] +requires = ["scikit-build-core>=0.10", "nanobind>=2.0"] +build-backend = "scikit_build_core.build" + +[project] +name = "RocketSim" +version = "1.0.0" +description = "Python bindings for RocketSim - Rocket League simulation at maximum efficiency" +readme = "README.md" +requires-python = ">=3.8" +license = {text = "MIT"} +keywords = ["rocket-league", "simulation", "rlgym", "reinforcement-learning", "physics"] +dependencies = ["numpy"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: C++", + "Topic :: Games/Entertainment", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + "Topic :: Scientific/Engineering :: Physics", +] + +[project.urls] +Homepage = "https://github.com/ZealanL/RocketSim" +Documentation = "https://zealanl.github.io/RocketSimDocs/" +Issues = "https://github.com/ZealanL/RocketSim/issues" + +[project.optional-dependencies] +dev = ["pytest>=7.0"] + +[tool.scikit-build] +minimum-version = "build-system.requires" +build-dir = "build/{wheel_tag}" + +[tool.scikit-build.wheel] +packages = [] + +[tool.scikit-build.cmake.define] +CMAKE_BUILD_TYPE = "Release" + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] diff --git a/python/src/bindings.cpp b/python/src/bindings.cpp new file mode 100644 index 0000000..f5b7101 --- /dev/null +++ b/python/src/bindings.cpp @@ -0,0 +1,329 @@ +#include +#include +#include +#include +#include + +#include "RocketSim.h" +#include "Math/Math.h" +#include "Sim/Arena/Arena.h" +#include "Sim/Car/Car.h" +#include "Sim/Ball/Ball.h" +#include "Sim/BoostPad/BoostPad.h" +#include "Sim/CarControls.h" +#include "Sim/MutatorConfig/MutatorConfig.h" + +namespace nb = nanobind; +using namespace nb::literals; +using namespace RocketSim; + +NB_MODULE(RocketSim, m) { + m.doc() = "RocketSim - A C++ library for simulating Rocket League games at maximum efficiency"; + + // ========== Module-level init function ========== + m.def("init", [](const std::string& path) { + RocketSim::Init(path); + }, "collision_meshes_path"_a, "Initialize RocketSim with path to collision meshes directory"); + + // ========== GameMode enum ========== + nb::enum_(m, "GameMode") + .value("SOCCAR", GameMode::SOCCAR) + .value("HOOPS", GameMode::HOOPS) + .value("HEATSEEKER", GameMode::HEATSEEKER) + .value("SNOWDAY", GameMode::SNOWDAY) + .value("DROPSHOT", GameMode::DROPSHOT) + .value("THE_VOID", GameMode::THE_VOID); + + // ========== Team enum ========== + nb::enum_(m, "Team") + .value("BLUE", Team::BLUE) + .value("ORANGE", Team::ORANGE); + + // ========== DemoMode enum ========== + nb::enum_(m, "DemoMode") + .value("NORMAL", DemoMode::NORMAL) + .value("ON_CONTACT", DemoMode::ON_CONTACT) + .value("DISABLED", DemoMode::DISABLED); + + // ========== Vec class ========== + nb::class_(m, "Vec") + .def(nb::init(), "x"_a = 0.0f, "y"_a = 0.0f, "z"_a = 0.0f) + .def_rw("x", &Vec::x) + .def_rw("y", &Vec::y) + .def_rw("z", &Vec::z) + .def("__repr__", [](const Vec& v) { + return "Vec(" + std::to_string(v.x) + ", " + std::to_string(v.y) + ", " + std::to_string(v.z) + ")"; + }) + .def("as_tuple", [](const Vec& v) { + return nb::make_tuple(v.x, v.y, v.z); + }) + .def("as_numpy", [](const Vec& v) { + float* data = new float[3]{v.x, v.y, v.z}; + nb::capsule owner(data, [](void* p) noexcept { delete[] static_cast(p); }); + return nb::ndarray>(data, {3}, owner); + }); + + // ========== RotMat class ========== + nb::class_(m, "RotMat") + .def(nb::init<>()) + .def(nb::init(), "forward"_a, "right"_a, "up"_a) + .def("__init__", [](RotMat* self, nb::ndarray> arr) { + auto data = arr.data(); + // Flattened row-major: [f.x, f.y, f.z, r.x, r.y, r.z, u.x, u.y, u.z] + new (self) RotMat( + Vec(data[0], data[1], data[2]), + Vec(data[3], data[4], data[5]), + Vec(data[6], data[7], data[8]) + ); + }) + .def_rw("forward", &RotMat::forward) + .def_rw("right", &RotMat::right) + .def_rw("up", &RotMat::up) + .def("__repr__", [](const RotMat& m) { + return "RotMat(forward=" + std::to_string(m.forward.x) + "," + std::to_string(m.forward.y) + "," + std::to_string(m.forward.z) + ")"; + }) + .def("as_numpy", [](const RotMat& m) { + // Return as 3x3 matrix (row-major for numpy compatibility) + float* data = new float[9]{ + m.forward.x, m.forward.y, m.forward.z, + m.right.x, m.right.y, m.right.z, + m.up.x, m.up.y, m.up.z + }; + nb::capsule owner(data, [](void* p) noexcept { delete[] static_cast(p); }); + return nb::ndarray>(data, {3, 3}, owner); + }) + .def_static("get_identity", &RotMat::GetIdentity); + + // ========== Angle class ========== + nb::class_(m, "Angle") + .def(nb::init(), "yaw"_a = 0.0f, "pitch"_a = 0.0f, "roll"_a = 0.0f) + .def_rw("yaw", &Angle::yaw) + .def_rw("pitch", &Angle::pitch) + .def_rw("roll", &Angle::roll) + .def("to_rot_mat", &Angle::ToRotMat) + .def_static("from_rot_mat", &Angle::FromRotMat); + + // ========== CarControls class ========== + nb::class_(m, "CarControls") + .def(nb::init<>()) + .def_rw("throttle", &CarControls::throttle) + .def_rw("steer", &CarControls::steer) + .def_rw("pitch", &CarControls::pitch) + .def_rw("yaw", &CarControls::yaw) + .def_rw("roll", &CarControls::roll) + .def_rw("boost", &CarControls::boost) + .def_rw("jump", &CarControls::jump) + .def_rw("handbrake", &CarControls::handbrake) + .def("clamp_fix", &CarControls::ClampFix); + + // ========== BallState class ========== + nb::class_(m, "BallState") + .def(nb::init<>()) + .def_rw("pos", &BallState::pos) + .def_rw("vel", &BallState::vel) + .def_rw("ang_vel", &BallState::angVel) + .def_rw("rot_mat", &BallState::rotMat); + + // ========== BoostPadState class ========== + nb::class_(m, "BoostPadState") + .def(nb::init<>()) + .def_rw("is_active", &BoostPadState::isActive) + .def_rw("cooldown", &BoostPadState::cooldown); + + // ========== WheelPairConfig class ========== + nb::class_(m, "WheelPairConfig") + .def(nb::init<>()) + .def_rw("wheel_radius", &WheelPairConfig::wheelRadius) + .def_rw("suspension_rest_length", &WheelPairConfig::suspensionRestLength) + .def_rw("connection_point_offset", &WheelPairConfig::connectionPointOffset); + + // ========== CarConfig class ========== + nb::class_(m, "CarConfig") + .def(nb::init<>()) + .def("__init__", [](CarConfig* self, int hitbox_type) { + static const CarConfig* configs[] = { + &CAR_CONFIG_OCTANE, + &CAR_CONFIG_DOMINUS, + &CAR_CONFIG_PLANK, + &CAR_CONFIG_BREAKOUT, + &CAR_CONFIG_HYBRID, + &CAR_CONFIG_MERC, + }; + if (hitbox_type < 0 || hitbox_type > 5) hitbox_type = 0; + new (self) CarConfig(*configs[hitbox_type]); + }, "hitbox_type"_a = 0) + .def_rw("hitbox_size", &CarConfig::hitboxSize) + .def_rw("hitbox_pos_offset", &CarConfig::hitboxPosOffset) + .def_rw("front_wheels", &CarConfig::frontWheels) + .def_rw("back_wheels", &CarConfig::backWheels) + .def_rw("dodge_deadzone", &CarConfig::dodgeDeadzone); + + // ========== CarState class ========== + nb::class_(m, "CarState") + .def(nb::init<>()) + // Position and rotation + .def_rw("pos", &CarState::pos) + .def_rw("rot_mat", &CarState::rotMat) + .def_rw("vel", &CarState::vel) + .def_rw("ang_vel", &CarState::angVel) + // Ground contact + .def_rw("is_on_ground", &CarState::isOnGround) + .def_prop_rw("wheels_with_contact", + [](const CarState& s) { + nb::list result; + for (int i = 0; i < 4; i++) result.append(s.wheelsWithContact[i]); + return result; + }, + [](CarState& s, nb::list wheels) { + for (size_t i = 0; i < 4 && i < nb::len(wheels); i++) { + s.wheelsWithContact[i] = nb::cast(wheels[i]); + } + }) + // Jump state + .def_rw("has_jumped", &CarState::hasJumped) + .def_rw("is_jumping", &CarState::isJumping) + .def_rw("jump_time", &CarState::jumpTime) + // Double jump + .def_rw("has_double_jumped", &CarState::hasDoubleJumped) + .def_rw("air_time_since_jump", &CarState::airTimeSinceJump) + // Flip state + .def_rw("has_flipped", &CarState::hasFlipped) + .def_rw("is_flipping", &CarState::isFlipping) + .def_rw("flip_time", &CarState::flipTime) + .def_rw("flip_rel_torque", &CarState::flipRelTorque) + // Auto-flip + .def_rw("is_auto_flipping", &CarState::isAutoFlipping) + .def_rw("auto_flip_timer", &CarState::autoFlipTimer) + .def_rw("auto_flip_torque_scale", &CarState::autoFlipTorqueScale) + // Boost + .def_rw("boost", &CarState::boost) + .def_rw("time_spent_boosting", &CarState::boostingTime) + // Supersonic + .def_rw("is_supersonic", &CarState::isSupersonic) + .def_rw("supersonic_time", &CarState::supersonicTime) + // Handbrake + .def_rw("handbrake_val", &CarState::handbrakeVal) + // Demo + .def_rw("is_demoed", &CarState::isDemoed) + .def_rw("demo_respawn_timer", &CarState::demoRespawnTimer) + // Car contact + .def_prop_rw("car_contact_id", + [](const CarState& s) { return s.carContact.otherCarID; }, + [](CarState& s, uint32_t id) { s.carContact.otherCarID = id; }) + .def_prop_rw("car_contact_cooldown_timer", + [](const CarState& s) { return s.carContact.cooldownTimer; }, + [](CarState& s, float t) { s.carContact.cooldownTimer = t; }) + // Last controls + .def_rw("last_controls", &CarState::lastControls); + + // ========== MutatorConfig class ========== + nb::class_(m, "MutatorConfig") + .def(nb::init(), "game_mode"_a = GameMode::SOCCAR) + .def_rw("gravity", &MutatorConfig::gravity) + .def_rw("car_mass", &MutatorConfig::carMass) + .def_rw("car_world_friction", &MutatorConfig::carWorldFriction) + .def_rw("car_world_restitution", &MutatorConfig::carWorldRestitution) + .def_rw("ball_mass", &MutatorConfig::ballMass) + .def_rw("ball_max_speed", &MutatorConfig::ballMaxSpeed) + .def_rw("ball_drag", &MutatorConfig::ballDrag) + .def_rw("ball_world_friction", &MutatorConfig::ballWorldFriction) + .def_rw("ball_world_restitution", &MutatorConfig::ballWorldRestitution) + .def_rw("ball_radius", &MutatorConfig::ballRadius) + .def_rw("jump_accel", &MutatorConfig::jumpAccel) + .def_rw("jump_immediate_force", &MutatorConfig::jumpImmediateForce) + .def_rw("boost_accel_ground", &MutatorConfig::boostAccelGround) + .def_rw("boost_accel_air", &MutatorConfig::boostAccelAir) + .def_rw("boost_used_per_second", &MutatorConfig::boostUsedPerSecond) + .def_rw("respawn_delay", &MutatorConfig::respawnDelay) + .def_rw("bump_cooldown_time", &MutatorConfig::bumpCooldownTime) + .def_rw("boost_pad_cooldown_big", &MutatorConfig::boostPadCooldown_Big) + .def_rw("boost_pad_cooldown_small", &MutatorConfig::boostPadCooldown_Small) + .def_rw("car_spawn_boost_amount", &MutatorConfig::carSpawnBoostAmount) + .def_rw("ball_hit_extra_force_scale", &MutatorConfig::ballHitExtraForceScale) + .def_rw("bump_force_scale", &MutatorConfig::bumpForceScale) + .def_rw("unlimited_flips", &MutatorConfig::unlimitedFlips) + .def_rw("unlimited_double_jumps", &MutatorConfig::unlimitedDoubleJumps) + .def_rw("demo_mode", &MutatorConfig::demoMode) + .def_rw("enable_team_demos", &MutatorConfig::enableTeamDemos); + + // ========== Ball class ========== + nb::class_(m, "Ball") + .def("get_state", &Ball::GetState) + .def("set_state", &Ball::SetState) + .def("get_radius", &Ball::GetRadius); + + // ========== BoostPad class ========== + nb::class_(m, "BoostPad") + .def("get_state", &BoostPad::GetState) + .def("set_state", &BoostPad::SetState) + .def("get_pos", [](const BoostPad& p) { return p.config.pos; }) + .def_prop_ro("is_big", [](const BoostPad& p) { return p.config.isBig; }); + + // ========== Car class ========== + nb::class_(m, "Car") + .def("get_state", &Car::GetState) + .def("set_state", &Car::SetState) + .def("get_controls", [](const Car& c) { return c.controls; }) + .def("set_controls", [](Car& c, const CarControls& ctrl) { c.controls = ctrl; }) + .def("get_config", [](const Car& c) { return c.config; }) + .def("demolish", &Car::Demolish, "respawn_delay"_a = 3.0f) + .def("respawn", &Car::Respawn, "game_mode"_a, "seed"_a = -1, "boost_amount"_a = 33.33f) + .def_prop_ro("id", [](const Car& c) { return c.id; }) + .def_prop_ro("team", [](const Car& c) { return static_cast(c.team); }); + + // ========== Arena class ========== + nb::class_(m, "Arena") + .def(nb::new_([](GameMode gameMode, float tickRate) { + return Arena::Create(gameMode, ArenaConfig{}, tickRate); + }), "game_mode"_a, "tick_rate"_a = 120.0f) + .def("step", &Arena::Step, "ticks_to_simulate"_a = 1) + .def("clone", [](Arena* a) { return a->Clone(false); }, nb::rv_policy::take_ownership) + .def("add_car", [](Arena* a, Team team, const CarConfig& config) { + return a->AddCar(team, config); + }, "team"_a, "config"_a, nb::rv_policy::reference) + .def("remove_car", [](Arena* a, Car* car) { a->RemoveCar(car); }) + .def("get_cars", [](Arena* a) { + std::vector cars; + for (auto* car : a->GetCars()) { + cars.push_back(car); + } + return cars; + }, nb::rv_policy::reference) + .def("get_car_from_id", &Arena::GetCar, "car_id"_a, nb::rv_policy::reference) + .def("get_boost_pads", [](Arena* a) { + return a->GetBoostPads(); + }, nb::rv_policy::reference) + .def("set_mutator_config", &Arena::SetMutatorConfig) + .def("get_mutator_config", &Arena::GetMutatorConfig, nb::rv_policy::reference) + .def("reset_to_random_kickoff", &Arena::ResetToRandomKickoff, "seed"_a = -1) + .def("is_ball_probably_going_in", &Arena::IsBallProbablyGoingIn, + "max_time"_a = 2.0f, "extra_margin"_a = 0.0f, "goal_team_out"_a = nullptr) + // Ball touch callback - stores Python callable + .def("set_ball_touch_callback", [](Arena* a, nb::object callback) { + // Note: RocketSim doesn't have a direct ball touch callback + // This would need to be implemented via the goal score callback or custom logic + // For now, we'll store it but it won't be called automatically + // Real implementation would require modifying RocketSim C++ code + }, "callback"_a) + .def_prop_ro("ball", [](Arena* a) { return a->ball; }, nb::rv_policy::reference) + .def_prop_ro("game_mode", [](const Arena& a) { return a.gameMode; }) + .def_prop_ro("tick_count", [](const Arena& a) { return a.tickCount; }) + .def_prop_ro("tick_rate", &Arena::GetTickRate) + .def_prop_ro("tick_time", [](const Arena& a) { return a.tickTime; }); + + // ========== Car config presets ========== + m.attr("CAR_CONFIG_OCTANE") = &CAR_CONFIG_OCTANE; + m.attr("CAR_CONFIG_DOMINUS") = &CAR_CONFIG_DOMINUS; + m.attr("CAR_CONFIG_PLANK") = &CAR_CONFIG_PLANK; + m.attr("CAR_CONFIG_BREAKOUT") = &CAR_CONFIG_BREAKOUT; + m.attr("CAR_CONFIG_HYBRID") = &CAR_CONFIG_HYBRID; + m.attr("CAR_CONFIG_MERC") = &CAR_CONFIG_MERC; + + // ========== Hitbox type constants ========== + m.attr("OCTANE") = 0; + m.attr("DOMINUS") = 1; + m.attr("PLANK") = 2; + m.attr("BREAKOUT") = 3; + m.attr("HYBRID") = 4; + m.attr("MERC") = 5; +} diff --git a/python/tests/__init__.py b/python/tests/__init__.py new file mode 100644 index 0000000..e4fcc47 --- /dev/null +++ b/python/tests/__init__.py @@ -0,0 +1,2 @@ +# Python tests for RocketSim + diff --git a/python/tests/conftest.py b/python/tests/conftest.py new file mode 100644 index 0000000..7e76988 --- /dev/null +++ b/python/tests/conftest.py @@ -0,0 +1,30 @@ +import pytest +import os +import RocketSim as rs + + +@pytest.fixture(scope="session", autouse=True) +def init_rocketsim(): + """Initialize RocketSim once for all tests.""" + # Find collision_meshes directory relative to this file + tests_dir = os.path.dirname(__file__) + python_dir = os.path.dirname(tests_dir) + repo_root = os.path.dirname(python_dir) + collision_meshes = os.path.join(repo_root, "collision_meshes") + + rs.init(collision_meshes) + yield + + +@pytest.fixture +def arena(): + """Create a fresh arena for each test.""" + return rs.Arena(rs.GameMode.SOCCAR) + + +@pytest.fixture +def arena_with_car(arena): + """Create an arena with one car.""" + car = arena.add_car(rs.Team.BLUE, rs.CAR_CONFIG_OCTANE) + return arena, car + diff --git a/python/tests/test_arena.py b/python/tests/test_arena.py new file mode 100644 index 0000000..a3bbb37 --- /dev/null +++ b/python/tests/test_arena.py @@ -0,0 +1,134 @@ +"""Tests for the Arena class.""" + +import pytest # noqa: F401 +import RocketSim as rs + + +class TestArenaCreation: + """Test Arena creation.""" + + def test_create_soccar_arena(self): + arena = rs.Arena(rs.GameMode.SOCCAR) + assert arena.game_mode == rs.GameMode.SOCCAR + assert arena.tick_count == 0 + assert abs(arena.tick_rate - 120.0) < 0.01 # Float comparison + + def test_custom_tick_rate(self): + arena = rs.Arena(rs.GameMode.SOCCAR, tick_rate=60.0) + assert abs(arena.tick_rate - 60.0) < 0.01 # Float comparison + + +class TestArenaStep: + """Test Arena simulation stepping.""" + + def test_step_increases_tick_count(self, arena): + initial_tick = arena.tick_count + arena.step(1) + assert arena.tick_count == initial_tick + 1 + + def test_step_multiple_ticks(self, arena): + initial_tick = arena.tick_count + arena.step(10) + assert arena.tick_count == initial_tick + 10 + + +class TestArenaClone: + """Test Arena cloning.""" + + def test_clone_creates_copy(self, arena_with_car): + arena, car = arena_with_car + arena.step(10) + + cloned = arena.clone() + + assert cloned.tick_count == arena.tick_count + assert cloned.game_mode == arena.game_mode + assert len(cloned.get_cars()) == len(arena.get_cars()) + + def test_clone_is_independent(self, arena_with_car): + arena, car = arena_with_car + arena.step(10) + + cloned = arena.clone() + arena.step(5) + + assert arena.tick_count != cloned.tick_count + + +class TestArenaBall: + """Test Arena ball access.""" + + def test_ball_exists(self, arena): + ball = arena.ball + assert ball is not None + + def test_ball_initial_position(self, arena): + state = arena.ball.get_state() + # Ball should start slightly above ground at center + assert state.pos.x == 0.0 + assert state.pos.y == 0.0 + assert state.pos.z > 0.0 # Above ground + + +class TestArenaCars: + """Test Arena car management.""" + + def test_add_car(self, arena): + car = arena.add_car(rs.Team.BLUE, rs.CAR_CONFIG_OCTANE) + assert car is not None + # Team is returned as int (0 = BLUE, 1 = ORANGE) + assert car.team == 0 or car.team == rs.Team.BLUE + + def test_add_multiple_cars(self, arena): + _car1 = arena.add_car(rs.Team.BLUE, rs.CAR_CONFIG_OCTANE) + _car2 = arena.add_car(rs.Team.ORANGE, rs.CAR_CONFIG_DOMINUS) + + cars = arena.get_cars() + assert len(cars) == 2 + + def test_get_car_by_id(self, arena): + car = arena.add_car(rs.Team.BLUE, rs.CAR_CONFIG_OCTANE) + car_id = car.id + + retrieved = arena.get_car_from_id(car_id) + assert retrieved.id == car_id + + def test_remove_car(self, arena): + car = arena.add_car(rs.Team.BLUE, rs.CAR_CONFIG_OCTANE) + assert len(arena.get_cars()) == 1 + + arena.remove_car(car) + assert len(arena.get_cars()) == 0 + + +class TestArenaBoostPads: + """Test Arena boost pad access.""" + + def test_boost_pads_exist(self, arena): + pads = arena.get_boost_pads() + assert len(pads) == 34 # Standard Soccar has 34 boost pads + + def test_boost_pad_properties(self, arena): + pads = arena.get_boost_pads() + big_pads = [p for p in pads if p.is_big] + small_pads = [p for p in pads if not p.is_big] + + assert len(big_pads) == 6 # 6 big boost pads + assert len(small_pads) == 28 # 28 small boost pads + + +class TestArenaMutators: + """Test Arena mutator config.""" + + def test_get_mutator_config(self, arena): + config = arena.get_mutator_config() + assert config is not None + + def test_set_mutator_config(self, arena): + config = rs.MutatorConfig() + config.gravity.z = -500.0 # Moon gravity + arena.set_mutator_config(config) + + # Verify it was set (get_mutator_config returns reference) + new_config = arena.get_mutator_config() + assert new_config.gravity.z == -500.0 diff --git a/python/tests/test_ball.py b/python/tests/test_ball.py new file mode 100644 index 0000000..11ba79d --- /dev/null +++ b/python/tests/test_ball.py @@ -0,0 +1,79 @@ +"""Tests for the Ball class.""" + +import pytest # noqa: F401 +import RocketSim as rs + + +class TestBallState: + """Test Ball state management.""" + + def test_get_state(self, arena): + state = arena.ball.get_state() + + assert isinstance(state.pos, rs.Vec) + assert isinstance(state.vel, rs.Vec) + assert isinstance(state.ang_vel, rs.Vec) + assert isinstance(state.rot_mat, rs.RotMat) + + def test_set_state(self, arena): + ball = arena.ball + state = ball.get_state() + + # Modify state + state.pos.x = 100.0 + state.pos.y = 200.0 + state.pos.z = 300.0 + state.vel.x = 10.0 + state.vel.y = 20.0 + state.vel.z = 30.0 + + ball.set_state(state) + + new_state = ball.get_state() + assert abs(new_state.pos.x - 100.0) < 0.01 + assert abs(new_state.pos.y - 200.0) < 0.01 + assert abs(new_state.pos.z - 300.0) < 0.01 + assert abs(new_state.vel.x - 10.0) < 0.01 + + +class TestBallPhysics: + """Test Ball physics simulation.""" + + def test_ball_falls_with_gravity(self, arena): + ball = arena.ball + state = ball.get_state() + + # Set ball in the air with zero velocity + state.pos.x = 0.0 + state.pos.y = 0.0 + state.pos.z = 500.0 + state.vel.x = 0.0 + state.vel.y = 0.0 + state.vel.z = 0.0 + state.ang_vel.x = 0.0 + state.ang_vel.y = 0.0 + state.ang_vel.z = 0.0 + ball.set_state(state) + + arena.step(60) + + new_state = ball.get_state() + # Ball should have fallen (or at least not gone up) + assert new_state.pos.z <= 500.0 + + def test_ball_maintains_velocity(self, arena): + ball = arena.ball + state = ball.get_state() + + # Set ball moving horizontally + state.pos.z = 200.0 # Above ground + state.vel.x = 1000.0 + state.vel.y = 0.0 + state.vel.z = 0.0 + ball.set_state(state) + + arena.step(12) # 0.1 seconds + + new_state = ball.get_state() + # Ball should have moved in x direction + assert new_state.pos.x > 0.0 diff --git a/python/tests/test_basic.py b/python/tests/test_basic.py new file mode 100644 index 0000000..32b3174 --- /dev/null +++ b/python/tests/test_basic.py @@ -0,0 +1,108 @@ +"""Basic tests for RocketSim Python bindings.""" +import pytest +import numpy as np +import RocketSim as rs + + +class TestImport: + """Test that the module imports correctly.""" + + def test_module_has_expected_attributes(self): + """Check that all expected classes and functions exist.""" + expected = [ + 'init', 'Arena', 'Ball', 'Car', 'BoostPad', + 'Vec', 'RotMat', 'Angle', + 'BallState', 'CarState', 'CarControls', 'CarConfig', + 'BoostPadState', 'MutatorConfig', + 'GameMode', 'Team', 'DemoMode', + 'CAR_CONFIG_OCTANE', 'CAR_CONFIG_DOMINUS', + 'CAR_CONFIG_PLANK', 'CAR_CONFIG_BREAKOUT', + 'CAR_CONFIG_HYBRID', 'CAR_CONFIG_MERC', + ] + for name in expected: + assert hasattr(rs, name), f"Missing attribute: {name}" + + +class TestVec: + """Test the Vec class.""" + + def test_default_constructor(self): + v = rs.Vec() + assert v.x == 0.0 + assert v.y == 0.0 + assert v.z == 0.0 + + def test_constructor_with_args(self): + v = rs.Vec(1.0, 2.0, 3.0) + assert v.x == 1.0 + assert v.y == 2.0 + assert v.z == 3.0 + + def test_as_numpy(self): + v = rs.Vec(1.0, 2.0, 3.0) + arr = v.as_numpy() + assert isinstance(arr, np.ndarray) + assert arr.shape == (3,) + np.testing.assert_array_almost_equal(arr, [1.0, 2.0, 3.0]) + + +class TestRotMat: + """Test the RotMat class.""" + + def test_default_constructor(self): + r = rs.RotMat() + assert isinstance(r.forward, rs.Vec) + assert isinstance(r.right, rs.Vec) + assert isinstance(r.up, rs.Vec) + + def test_as_numpy(self): + r = rs.RotMat() + arr = r.as_numpy() + assert isinstance(arr, np.ndarray) + assert arr.shape == (3, 3) + + +class TestAngle: + """Test the Angle class.""" + + def test_default_constructor(self): + a = rs.Angle() + assert a.yaw == 0.0 + assert a.pitch == 0.0 + assert a.roll == 0.0 + + def test_constructor_with_args(self): + a = rs.Angle(1.0, 2.0, 3.0) + assert a.yaw == 1.0 + assert a.pitch == 2.0 + assert a.roll == 3.0 + + +class TestGameMode: + """Test GameMode enum.""" + + def test_game_modes_exist(self): + assert rs.GameMode.SOCCAR is not None + assert rs.GameMode.HOOPS is not None + assert rs.GameMode.HEATSEEKER is not None + assert rs.GameMode.SNOWDAY is not None + assert rs.GameMode.DROPSHOT is not None + assert rs.GameMode.THE_VOID is not None + + +class TestTeam: + """Test Team enum.""" + + def test_teams_exist(self): + assert rs.Team.BLUE is not None + assert rs.Team.ORANGE is not None + + +class TestDemoMode: + """Test DemoMode enum.""" + + def test_demo_modes_exist(self): + assert rs.DemoMode.NORMAL is not None + assert rs.DemoMode.ON_CONTACT is not None + assert rs.DemoMode.DISABLED is not None + diff --git a/python/tests/test_boost_pad.py b/python/tests/test_boost_pad.py new file mode 100644 index 0000000..89113e0 --- /dev/null +++ b/python/tests/test_boost_pad.py @@ -0,0 +1,56 @@ +"""Tests for the BoostPad class.""" + +import pytest # noqa: F401 +import RocketSim as rs + + +class TestBoostPadProperties: + """Test BoostPad properties.""" + + def test_boost_pad_has_position(self, arena): + pads = arena.get_boost_pads() + pad = pads[0] + + pos = pad.get_pos() + assert isinstance(pos, rs.Vec) + + def test_boost_pad_is_big(self, arena): + pads = arena.get_boost_pads() + + # Find a big and small pad + big_pads = [p for p in pads if p.is_big] + small_pads = [p for p in pads if not p.is_big] + + assert len(big_pads) > 0 + assert len(small_pads) > 0 + + +class TestBoostPadState: + """Test BoostPad state management.""" + + def test_get_state(self, arena): + pad = arena.get_boost_pads()[0] + state = pad.get_state() + + assert hasattr(state, "is_active") + assert hasattr(state, "cooldown") + + def test_pad_starts_active(self, arena): + pad = arena.get_boost_pads()[0] + state = pad.get_state() + + assert state.is_active + assert state.cooldown == 0.0 + + def test_set_state(self, arena): + pad = arena.get_boost_pads()[0] + + state = rs.BoostPadState() + state.is_active = False + state.cooldown = 5.0 + + pad.set_state(state) + + new_state = pad.get_state() + assert not new_state.is_active + assert new_state.cooldown == 5.0 diff --git a/python/tests/test_car.py b/python/tests/test_car.py new file mode 100644 index 0000000..f469654 --- /dev/null +++ b/python/tests/test_car.py @@ -0,0 +1,149 @@ +"""Tests for the Car class.""" + +import pytest # noqa: F401 +import RocketSim as rs + + +class TestCarProperties: + """Test Car properties.""" + + def test_car_id(self, arena_with_car): + arena, car = arena_with_car + assert isinstance(car.id, int) + assert car.id > 0 + + def test_car_team(self, arena_with_car): + arena, car = arena_with_car + # Team is returned as int (0 = BLUE, 1 = ORANGE) + assert car.team == 0 or car.team == rs.Team.BLUE + + +class TestCarState: + """Test Car state management.""" + + def test_get_state(self, arena_with_car): + arena, car = arena_with_car + state = car.get_state() + + assert isinstance(state.pos, rs.Vec) + assert isinstance(state.vel, rs.Vec) + assert isinstance(state.ang_vel, rs.Vec) + assert isinstance(state.rot_mat, rs.RotMat) + + def test_set_state(self, arena_with_car): + arena, car = arena_with_car + state = car.get_state() + + # Modify position + state.pos.x = 1000.0 + state.pos.y = 500.0 + state.pos.z = 100.0 + state.boost = 100.0 + + car.set_state(state) + + new_state = car.get_state() + assert new_state.pos.x == 1000.0 + assert new_state.pos.y == 500.0 + assert new_state.boost == 100.0 + + def test_car_state_attributes(self, arena_with_car): + arena, car = arena_with_car + state = car.get_state() + + # Check all expected attributes exist + assert hasattr(state, "pos") + assert hasattr(state, "vel") + assert hasattr(state, "ang_vel") + assert hasattr(state, "rot_mat") + assert hasattr(state, "boost") + assert hasattr(state, "is_on_ground") + assert hasattr(state, "has_jumped") + assert hasattr(state, "has_double_jumped") + assert hasattr(state, "has_flipped") + assert hasattr(state, "is_supersonic") + + +class TestCarControls: + """Test Car controls.""" + + def test_set_controls(self, arena_with_car): + arena, car = arena_with_car + + controls = rs.CarControls() + controls.throttle = 1.0 + controls.steer = 0.5 + controls.boost = True + + car.set_controls(controls) + # Note: Controls are applied on next step + arena.step(1) + + def test_controls_default_values(self): + controls = rs.CarControls() + + assert controls.throttle == 0.0 + assert controls.steer == 0.0 + assert controls.pitch == 0.0 + assert controls.yaw == 0.0 + assert controls.roll == 0.0 + assert not controls.boost + assert not controls.jump + assert not controls.handbrake + + +class TestCarSimulation: + """Test car physics simulation.""" + + def test_car_falls_with_gravity(self, arena_with_car): + arena, car = arena_with_car + + # Set car in the air + state = car.get_state() + state.pos.z = 500.0 + state.vel.z = 0.0 + car.set_state(state) + + arena.step(60) # 0.5 seconds at 120 tick rate + + new_state = car.get_state() + # Car should have fallen + assert new_state.pos.z < 500.0 + + def test_car_moves_with_throttle(self, arena_with_car): + arena, car = arena_with_car + + initial_state = car.get_state() + initial_y = initial_state.pos.y + + # Apply throttle + controls = rs.CarControls() + controls.throttle = 1.0 + car.set_controls(controls) + + # Step simulation + arena.step(60) + + # Car should have moved forward (positive Y in Rocket League) + new_state = car.get_state() + assert new_state.pos.y > initial_y + + def test_car_boost_consumption(self, arena_with_car): + arena, car = arena_with_car + + # Set boost to 100 + state = car.get_state() + state.boost = 100.0 + car.set_state(state) + + # Apply boost + controls = rs.CarControls() + controls.throttle = 1.0 + controls.boost = True + car.set_controls(controls) + + arena.step(60) + + # Boost should have been consumed + new_state = car.get_state() + assert new_state.boost < 100.0 diff --git a/src/Sim/Arena/Arena.h b/src/Sim/Arena/Arena.h index 287fa65..c97ccb0 100644 --- a/src/Sim/Arena/Arena.h +++ b/src/Sim/Arena/Arena.h @@ -134,7 +134,7 @@ class Arena { Arena& operator =(Arena&& other) = delete; // No move operator // Get a deep copy of the arena - Arena* Clone(bool copyCallbacks); + Arena* Clone(bool copyCallbacks = false); // NOTE: Car ID will not be restored Car* DeserializeNewCar(DataStreamIn& in, Team team);