diff --git a/demo_ball_placement.py b/demo_ball_placement.py
new file mode 100644
index 00000000..166d5529
--- /dev/null
+++ b/demo_ball_placement.py
@@ -0,0 +1,146 @@
+"""demo_ball_placement.py — Entry point for the ball placement feature.
+
+Run:
+ pixi run python demo_ball_placement.py
+ # RSim window opens; open http://localhost:8080 in a browser
+
+What this sets up
+-----------------
+- Exhibition Road field (4 m × 3 m, ``GREAT_EXHIBITION_FIELD_DIMS``)
+- 2v2 format: two yellow robots (your team) vs two blue robots that
+ deliberately push the ball out once per side, then attack the yellow goal
+- CustomReferee pre-configured with:
+ - Goal detection enabled (issues PREPARE_KICKOFF → NORMAL_START cycle)
+ - Out-of-bounds enabled (issues STOP → BALL_PLACEMENT_YELLOW → DIRECT_FREE)
+ - Defense area and keep-out rules OFF (less noise during development)
+ - Auto-advance fully enabled so state transitions fire automatically —
+ you can watch the full placement → free-kick → restart cycle without
+ touching the GUI
+- Browser GUI at http://localhost:8080 — use "Manual Commands" to fire
+ BALL_PLACEMENT_YELLOW at any time and set the target position
+
+Workflow
+--------
+1. Run this script and open http://localhost:8080.
+2. Let the blue robots push the ball out of bounds, or use the GUI "Manual
+ Commands" panel to issue BALL_PLACEMENT_YELLOW.
+3. Watch one of your robots (yellow) drive to the ball, capture it with the
+ dribbler, and carry it to the target circle shown in the GUI.
+4. After the referee advances to NORMAL_START, yellow kicks once toward the
+ configured blue robot target.
+"""
+
+from utama_core.config.field_params import GREAT_EXHIBITION_FIELD_DIMS
+from utama_core.custom_referee import CustomReferee
+from utama_core.custom_referee.profiles.profile_loader import (
+ AutoAdvanceConfig,
+ DefenseAreaConfig,
+ GameConfig,
+ GoalDetectionConfig,
+ KeepOutConfig,
+ OutOfBoundsConfig,
+ RefereeProfile,
+ RulesConfig,
+)
+from utama_core.run import StrategyRunner
+from utama_core.strategy.examples.ball_placement_and_kick_strategy import (
+ BallPlacementAndKickStrategy,
+)
+from utama_core.strategy.examples.deliberate_out_of_bounds_strategy import (
+ DeliberateOutOfBoundsStrategy,
+)
+
+# ---------------------------------------------------------------------------
+# Configuration
+# ---------------------------------------------------------------------------
+
+GUI_PORT = 8080
+N_ROBOTS = 2
+MY_TEAM_IS_YELLOW = True
+MY_TEAM_IS_RIGHT = True
+
+
+# ---------------------------------------------------------------------------
+# Referee profile
+#
+# Out-of-bounds is the primary trigger for ball placement in a real game.
+# Defense area and keep-out rules are disabled to reduce noise while you're
+# developing the placement skill itself.
+# ---------------------------------------------------------------------------
+
+_BALL_PLACEMENT_PROFILE = RefereeProfile(
+ profile_name="ball_placement_dev",
+ rules=RulesConfig(
+ goal_detection=GoalDetectionConfig(
+ enabled=True,
+ cooldown_seconds=1.0,
+ ),
+ out_of_bounds=OutOfBoundsConfig(
+ enabled=True,
+ free_kick_assigner="last_touch",
+ ),
+ defense_area=DefenseAreaConfig(
+ enabled=False,
+ max_defenders=1,
+ attacker_infringement=False,
+ ),
+ keep_out=KeepOutConfig(
+ enabled=False,
+ radius_meters=0.3,
+ violation_persistence_frames=30,
+ ),
+ ),
+ game=GameConfig(
+ half_duration_seconds=300.0,
+ kickoff_team="yellow",
+ force_start_after_goal=False,
+ stop_duration_seconds=2.0,
+ prepare_duration_seconds=3.0,
+ kickoff_timeout_seconds=10.0,
+ auto_advance=AutoAdvanceConfig(
+ # All auto-advance enabled: state machine drives itself so you can
+ # observe the full placement → free-kick → normal-start cycle.
+ stop_to_next_command=True,
+ prepare_kickoff_to_normal=True,
+ prepare_penalty_to_normal=True,
+ direct_free_to_normal=True,
+ ball_placement_to_next=True,
+ normal_start_to_force=True,
+ ),
+ ),
+)
+
+# ---------------------------------------------------------------------------
+# Entry point
+# ---------------------------------------------------------------------------
+
+
+def main() -> None:
+ referee = CustomReferee(
+ _BALL_PLACEMENT_PROFILE,
+ n_robots_yellow=N_ROBOTS,
+ n_robots_blue=N_ROBOTS,
+ enable_gui=True,
+ gui_port=GUI_PORT,
+ )
+
+ runner = StrategyRunner(
+ strategy=BallPlacementAndKickStrategy(),
+ # Opponents create one out-of-bounds event per side, then attack yellow's goal.
+ opp_strategy=DeliberateOutOfBoundsStrategy(field_dims=GREAT_EXHIBITION_FIELD_DIMS),
+ my_team_is_yellow=MY_TEAM_IS_YELLOW,
+ my_team_is_right=MY_TEAM_IS_RIGHT,
+ mode="rsim",
+ control_scheme="pid",
+ exp_friendly=N_ROBOTS,
+ exp_enemy=N_ROBOTS,
+ full_field_dims=GREAT_EXHIBITION_FIELD_DIMS,
+ referee=referee,
+ show_live_status=True,
+ )
+
+ runner.run()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/demo_ball_placement_real.py b/demo_ball_placement_real.py
new file mode 100644
index 00000000..cac2962d
--- /dev/null
+++ b/demo_ball_placement_real.py
@@ -0,0 +1,133 @@
+"""demo_ball_placement_real.py — Test ball placement with a single real robot.
+
+Run:
+ pixi run python demo_ball_placement_real.py
+ # Then open http://localhost:8080 in a browser
+
+What this sets up
+-----------------
+- Exhibition Road field (4 m x 3 m)
+- 1 yellow robot, no opponents
+- CustomReferee starts in HALT — the robot will not move until you issue a
+ command from the GUI
+- All automatic rule detection (out-of-bounds, goal, keep-out) is disabled so
+ the only commands come from the operator via the GUI
+
+Workflow
+--------
+1. Run this script and open http://localhost:8080.
+2. Place the ball somewhere on the field by hand.
+3. In the GUI "Manual Commands" panel, set a target position and issue
+ BALL_PLACEMENT_YELLOW.
+4. The robot drives to the ball with its dribbler on and attempts to carry it
+ to the target.
+5. Issue HALT from the GUI at any time to stop the robot immediately.
+
+Safety notes
+------------
+- The robot starts in HALT and will not move until you issue a command.
+- Keep the target position within the physical field bounds.
+- Issue HALT from the GUI before approaching the field.
+"""
+
+from utama_core.config.field_params import GREAT_EXHIBITION_FIELD_DIMS
+from utama_core.custom_referee import CustomReferee
+from utama_core.custom_referee.profiles.profile_loader import (
+ AutoAdvanceConfig,
+ DefenseAreaConfig,
+ GameConfig,
+ GoalDetectionConfig,
+ KeepOutConfig,
+ OutOfBoundsConfig,
+ RefereeProfile,
+ RulesConfig,
+)
+from utama_core.run import StrategyRunner
+from utama_core.strategy.examples.ball_placement_strategy import BallPlacementStrategy
+
+# ---------------------------------------------------------------------------
+# Configuration -- edit these to match your setup
+# ---------------------------------------------------------------------------
+
+GUI_PORT = 8080
+MY_TEAM_IS_YELLOW = True
+MY_TEAM_IS_RIGHT = True
+
+# ---------------------------------------------------------------------------
+# Referee profile
+#
+# All automatic rules are OFF. Commands come from the operator only.
+# auto_advance is also OFF so the state never transitions without you.
+# ---------------------------------------------------------------------------
+
+_REAL_TEST_PROFILE = RefereeProfile(
+ profile_name="ball_placement_real_test",
+ rules=RulesConfig(
+ goal_detection=GoalDetectionConfig(
+ enabled=False,
+ cooldown_seconds=1.0,
+ ),
+ out_of_bounds=OutOfBoundsConfig(
+ enabled=False,
+ free_kick_assigner="last_touch",
+ ),
+ defense_area=DefenseAreaConfig(
+ enabled=False,
+ max_defenders=1,
+ attacker_infringement=False,
+ ),
+ keep_out=KeepOutConfig(
+ enabled=False,
+ radius_meters=0.3,
+ violation_persistence_frames=30,
+ ),
+ ),
+ game=GameConfig(
+ half_duration_seconds=300.0,
+ kickoff_team="yellow",
+ force_start_after_goal=False,
+ stop_duration_seconds=2.0,
+ prepare_duration_seconds=3.0,
+ kickoff_timeout_seconds=10.0,
+ auto_advance=AutoAdvanceConfig(
+ stop_to_next_command=False,
+ prepare_kickoff_to_normal=False,
+ prepare_penalty_to_normal=False,
+ direct_free_to_normal=False,
+ ball_placement_to_next=False,
+ normal_start_to_force=False,
+ ),
+ ),
+)
+
+# ---------------------------------------------------------------------------
+# Entry point
+# ---------------------------------------------------------------------------
+
+
+def main() -> None:
+ referee = CustomReferee(
+ _REAL_TEST_PROFILE,
+ n_robots_yellow=1,
+ n_robots_blue=0,
+ enable_gui=True,
+ gui_port=GUI_PORT,
+ )
+
+ runner = StrategyRunner(
+ strategy=BallPlacementStrategy(),
+ my_team_is_yellow=MY_TEAM_IS_YELLOW,
+ my_team_is_right=MY_TEAM_IS_RIGHT,
+ mode="real",
+ exp_friendly=1,
+ exp_enemy=0,
+ full_field_dims=GREAT_EXHIBITION_FIELD_DIMS,
+ referee=referee,
+ show_live_status=True,
+ )
+
+ runner.run()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/main.py b/main.py
index 75cd4870..d2c75271 100644
--- a/main.py
+++ b/main.py
@@ -25,7 +25,7 @@ def main():
strategy=RandomMovementStrategy(n_robots=2, field_bounds=custom_bounds, endpoint_tolerance=0.1, seed=42),
my_team_is_yellow=True,
my_team_is_right=True,
- mode="rsim",
+ mode="grsim",
exp_friendly=2,
exp_enemy=0,
replay_writer_config=ReplayWriterConfig(replay_name="test_replay", overwrite_existing=True),
diff --git a/utama_core/config/field_params.py b/utama_core/config/field_params.py
index 3af87bd5..2b99e91c 100644
--- a/utama_core/config/field_params.py
+++ b/utama_core/config/field_params.py
@@ -149,10 +149,10 @@ def __post_init__(self):
)
GREAT_EXHIBITION_FIELD_DIMS = FieldDimensions(
- full_field_half_length=2.0,
- full_field_half_width=1.5,
- half_defense_area_depth=0.4,
- half_defense_area_width=0.8,
- half_goal_width=0.5,
+ full_field_half_length=1.5,
+ full_field_half_width=1.125,
+ half_defense_area_depth=0.25,
+ half_defense_area_width=0.5,
+ half_goal_width=0.4,
center_circle_radius=0.3,
)
diff --git a/utama_core/config/physical_constants.py b/utama_core/config/physical_constants.py
index 624d793e..ce45f84d 100644
--- a/utama_core/config/physical_constants.py
+++ b/utama_core/config/physical_constants.py
@@ -1,3 +1,4 @@
ROBOT_RADIUS = 0.09
MAX_ROBOTS = 6
+MAX_ROBOT_ID = 15 # valid IDs from ssl small ruleset
BALL_RADIUS = 0.0215
diff --git a/utama_core/config/referee_constants.py b/utama_core/config/referee_constants.py
index 3e5333aa..bb70a4fb 100644
--- a/utama_core/config/referee_constants.py
+++ b/utama_core/config/referee_constants.py
@@ -2,6 +2,7 @@
BALL_KEEP_OUT_DISTANCE = 0.8
BALL_PLACEMENT_DONE_DISTANCE = 0.15
+KICKER_READY_DISTANCE = 0.3
OPPONENT_DEFENSE_AREA_KEEP_DISTANCE = 0.25
PENALTY_BEHIND_MARK_DISTANCE = 0.4
PENALTY_MARK_HALF_FIELD_RATIO = 0.5
diff --git a/utama_core/config/settings.py b/utama_core/config/settings.py
index d255f4aa..17c16f5d 100644
--- a/utama_core/config/settings.py
+++ b/utama_core/config/settings.py
@@ -29,7 +29,7 @@
### REAL CONTROLLER SETTINGS ###
BAUD_RATE = 115200
-PORT = "/dev/ttyUSB0"
+PORT = "/dev/ttyACM0"
TIMEOUT = 0.1
KICKER_COOLDOWN_TIME = 10 # in seconds to prevent kicker from being actuated too frequently
KICKER_COOLDOWN_TIMESTEPS = int(KICKER_COOLDOWN_TIME * CONTROL_FREQUENCY) # in timesteps
diff --git a/utama_core/custom_referee/custom_referee.py b/utama_core/custom_referee/custom_referee.py
index c5a7d956..cd69f154 100644
--- a/utama_core/custom_referee/custom_referee.py
+++ b/utama_core/custom_referee/custom_referee.py
@@ -169,6 +169,15 @@ def set_command(self, command: RefereeCommand, timestamp: float) -> None:
"""Manual override — for operator use or test scripting."""
self._state.set_command(command, timestamp)
+ def force_command(
+ self,
+ command: "RefereeCommand",
+ timestamp: float,
+ ball_placement_target=None,
+ ) -> None:
+ """God-mode override — bypasses the STOP-first guard."""
+ self._state.force_command(command, timestamp, ball_placement_target)
+
# ------------------------------------------------------------------
# Properties (read-only access for callers that need to inspect state)
# ------------------------------------------------------------------
diff --git a/utama_core/custom_referee/gui.py b/utama_core/custom_referee/gui.py
index b957e774..796f133c 100644
--- a/utama_core/custom_referee/gui.py
+++ b/utama_core/custom_referee/gui.py
@@ -229,8 +229,14 @@ def _handle_command(self):
try:
payload = json.loads(body)
cmd = RefereeCommand[payload["command"]]
+ designated = payload.get("designated")
with server_instance._lock:
- server_instance._referee.set_command(cmd, time.time())
+ ref = server_instance._referee
+ if designated is not None and hasattr(ref, "force_command"):
+ target = (float(designated[0]), float(designated[1]))
+ ref.force_command(cmd, time.time(), ball_placement_target=target)
+ else:
+ ref.set_command(cmd, time.time())
self.send_response(204)
self.end_headers()
except (KeyError, ValueError, json.JSONDecodeError) as exc:
@@ -657,6 +663,38 @@ def _build_static_config(profile: "RefereeProfile") -> dict:
}
.pill.on { background: #1a3a1a; color: var(--green); border: 1px solid #2a5a2a; }
.pill.off { background: #3a1a1a; color: #c0605a; border: 1px solid #5a2a2a; }
+
+ /* ── God mode ── */
+ .btn-god { background: #4a0d6e; color: #d49eff; border: 1px solid #7a3dae; }
+ .btn-god.active { background: #7a3dae; color: #fff; border-color: #d49eff; }
+
+ #ctx-menu {
+ display: none;
+ position: fixed;
+ z-index: 999;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ min-width: 180px;
+ box-shadow: 0 4px 16px rgba(0,0,0,.5);
+ overflow: hidden;
+ }
+ #ctx-menu button {
+ display: block;
+ width: 100%;
+ text-align: left;
+ border-radius: 0;
+ border: none;
+ background: none;
+ color: var(--text);
+ padding: 9px 14px;
+ font-size: .78rem;
+ text-transform: none;
+ letter-spacing: 0;
+ font-weight: 400;
+ }
+ #ctx-menu button:hover { background: var(--border); }
+ #ctx-menu .ctx-sep { height: 1px; background: var(--border); margin: 2px 0; }
@@ -728,6 +766,10 @@ def _build_static_config(profile: "RefereeProfile") -> dict:
Profile — loading…
@@ -1004,6 +1052,80 @@ def _build_static_config(profile: "RefereeProfile") -> dict:
return `
`;
}
+// --- God mode ---
+let _godMode = false;
+let _ctxFieldPos = null; // {x, y} in field coords at last right-click
+
+function toggleGod() {
+ _godMode = !_godMode;
+ const btn = document.getElementById('god-btn');
+ btn.classList.toggle('active', _godMode);
+ btn.title = _godMode
+ ? 'God mode ON — right-click on the field canvas to move the ball.'
+ : 'God mode: right-click anywhere on the field to move the ball or issue ball placement.';
+}
+
+function _canvasToField(canvas, clientX, clientY) {
+ if (!_cfg) return null;
+ const rect = canvas.getBoundingClientRect();
+ const cw = canvas.width, ch = canvas.height;
+ const M = 12;
+ const scale = (cw - 2 * M) / (2 * _cfg.half_length);
+ const px = (clientX - rect.left) * (cw / rect.width);
+ const py = (clientY - rect.top) * (ch / rect.height);
+ // Inverse of: toX(fx) = M + (fx + half_length) * scale
+ const fx = (px - M) / scale - _cfg.half_length;
+ // Inverse of: toY(fy) = CH - M - (fy + half_width) * scale
+ const fy = (ch - M - py) / scale - _cfg.half_width;
+ return { x: fx, y: fy };
+}
+
+function _hideCtxMenu() {
+ document.getElementById('ctx-menu').style.display = 'none';
+}
+
+document.addEventListener('click', _hideCtxMenu);
+document.addEventListener('keydown', e => { if (e.key === 'Escape') _hideCtxMenu(); });
+
+document.getElementById('field-canvas').addEventListener('contextmenu', function(e) {
+ if (!_godMode) return;
+ e.preventDefault();
+ _ctxFieldPos = _canvasToField(this, e.clientX, e.clientY);
+ if (!_ctxFieldPos) return;
+ const menu = document.getElementById('ctx-menu');
+ menu.style.display = 'block';
+ const mx = Math.min(e.clientX, window.innerWidth - menu.offsetWidth - 4);
+ const my = Math.min(e.clientY, window.innerHeight - menu.offsetHeight - 4);
+ menu.style.left = mx + 'px';
+ menu.style.top = my + 'px';
+});
+
+function ctxPlaceBallYellow() {
+ _hideCtxMenu();
+ if (!_ctxFieldPos) return;
+ fetch('/command', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ command: 'BALL_PLACEMENT_YELLOW',
+ designated: [_ctxFieldPos.x, _ctxFieldPos.y],
+ }),
+ }).catch(err => console.error('command error:', err));
+}
+
+function ctxPlaceBallBlue() {
+ _hideCtxMenu();
+ if (!_ctxFieldPos) return;
+ fetch('/command', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ command: 'BALL_PLACEMENT_BLUE',
+ designated: [_ctxFieldPos.x, _ctxFieldPos.y],
+ }),
+ }).catch(err => console.error('command error:', err));
+}
+
fetch('/config').then(r => r.json()).then(c => {
document.getElementById('page-title').textContent = 'Custom Referee — ' + c.profile_name;
document.getElementById('cfg-title').textContent = 'Profile: ' + c.profile_name;
diff --git a/utama_core/custom_referee/state_machine.py b/utama_core/custom_referee/state_machine.py
index f194bdcd..aa465de7 100644
--- a/utama_core/custom_referee/state_machine.py
+++ b/utama_core/custom_referee/state_machine.py
@@ -1,633 +1,710 @@
-"""GameStateMachine: owns all mutable game state for the CustomReferee."""
-
-from __future__ import annotations
-
-import copy
-import logging
-import math
-from typing import Optional
-
-from utama_core.config.field_params import STANDARD_FIELD_DIMS
-from utama_core.custom_referee.geometry import RefereeGeometry
-from utama_core.custom_referee.profiles.profile_loader import AutoAdvanceConfig
-from utama_core.custom_referee.rules.base_rule import RuleViolation
-from utama_core.entities.data.referee import RefereeData
-from utama_core.entities.game.game_frame import GameFrame
-from utama_core.entities.game.team_info import TeamInfo
-from utama_core.entities.referee.referee_command import RefereeCommand
-from utama_core.entities.referee.stage import Stage
-
-logger = logging.getLogger(__name__)
-
-_TRANSITION_COOLDOWN = 0.3 # seconds — prevents command oscillation
-_BALL_CLEAR_DIST = 0.5 # metres — all robots must be this far from ball before queued restart
-_KICKER_READY_DIST = 0.3 # metres — kicker must be within this distance to trigger free kick start
-_PLACEMENT_DONE_DIST = 0.15 # metres — ball within this dist of target → placement complete
-_AUTO_ADVANCE_DELAY = 2.0 # seconds — readiness must be sustained this long before play starts
-
-
-class GameStateMachine:
- """Owns score, command, and stage. Produces ``RefereeData`` each tick."""
-
- def __init__(
- self,
- half_duration_seconds: float,
- kickoff_team: str,
- n_robots_yellow: int,
- n_robots_blue: int,
- initial_stage: Stage = Stage.NORMAL_FIRST_HALF_PRE,
- force_start_after_goal: bool = False,
- stop_duration_seconds: float = 3.0,
- prepare_duration_seconds: float = 3.0,
- kickoff_timeout_seconds: float = 10.0,
- geometry: Optional[RefereeGeometry] = None,
- auto_advance: Optional[AutoAdvanceConfig] = None,
- ) -> None:
- self.command = RefereeCommand.HALT
- self.command_counter = 0
- self.command_timestamp = 0.0
-
- self.stage = initial_stage
- # Seeded by seed_clock() after the first valid game frame is available.
- self.stage_start_time: Optional[float] = None
- self.stage_duration = half_duration_seconds
-
- self.yellow_team = TeamInfo(
- name="Yellow",
- score=0,
- red_cards=0,
- yellow_card_times=[],
- yellow_cards=0,
- timeouts=4,
- timeout_time=300,
- goalkeeper=0,
- foul_counter=0,
- ball_placement_failures=0,
- can_place_ball=True,
- max_allowed_bots=n_robots_yellow,
- bot_substitution_intent=False,
- bot_substitution_allowed=True,
- bot_substitutions_left=5,
- )
- self.blue_team = TeamInfo(
- name="Blue",
- score=0,
- red_cards=0,
- yellow_card_times=[],
- yellow_cards=0,
- timeouts=4,
- timeout_time=300,
- goalkeeper=0,
- foul_counter=0,
- ball_placement_failures=0,
- can_place_ball=True,
- max_allowed_bots=n_robots_blue,
- bot_substitution_intent=False,
- bot_substitution_allowed=True,
- bot_substitutions_left=5,
- )
-
- self.next_command: Optional[RefereeCommand] = None
- self.ball_placement_target: Optional[tuple[float, float]] = None
- self.status_message: Optional[str] = None
-
- # Kickoff team initialised from profile.
- self._kickoff_team_is_yellow = kickoff_team.lower() == "yellow"
-
- # Arcade auto-advance: after stop_duration_seconds in STOP following a
- # goal, automatically issue FORCE_START instead of waiting for operator.
- self._force_start_after_goal = force_start_after_goal
- self._stop_duration_seconds = stop_duration_seconds
- self._stop_entered_time: float = -math.inf # wall time when STOP was last entered
-
- # Auto-advance timings.
- self._prepare_duration_seconds = prepare_duration_seconds
- self._kickoff_timeout_seconds = kickoff_timeout_seconds
- self._prepare_entered_time: float = -math.inf # wall time when PREPARE_KICKOFF was entered
- self._normal_start_time: float = -math.inf # wall time when NORMAL_START was entered
-
- # Ball position snapshot at NORMAL_START — used to detect if the ball has moved.
- self._ball_pos_at_normal_start: Optional[tuple[float, float]] = None
-
- # Timestamps for sustained-readiness countdown before play-starting advances.
- # Set to math.inf when condition is not yet met; fire when elapsed >= _AUTO_ADVANCE_DELAY.
- self._advance2_ready_since: float = math.inf # PREPARE_* → NORMAL_START
- self._advance3_ready_since: float = math.inf # DIRECT_FREE_* → NORMAL_START
- self._advance4_ready_since: float = math.inf # BALL_PLACEMENT_* → next_command
-
- # Field geometry (used for readiness checks).
- self._geometry: Optional[RefereeGeometry] = geometry
-
- # Cooldown: don't process a new violation within this window.
- self._last_transition_time: float = -math.inf
-
- # Per-transition enable flags (default: all on).
- self._auto_advance = auto_advance if auto_advance is not None else AutoAdvanceConfig()
-
- # ------------------------------------------------------------------
- # Public API
- # ------------------------------------------------------------------
-
- _PREPARE_KICKOFF_COMMANDS = frozenset(
- {
- RefereeCommand.PREPARE_KICKOFF_YELLOW,
- RefereeCommand.PREPARE_KICKOFF_BLUE,
- }
- )
- _DIRECT_FREE_COMMANDS = frozenset(
- {
- RefereeCommand.DIRECT_FREE_YELLOW,
- RefereeCommand.DIRECT_FREE_BLUE,
- }
- )
- _PREPARE_PENALTY_COMMANDS = frozenset(
- {
- RefereeCommand.PREPARE_PENALTY_YELLOW,
- RefereeCommand.PREPARE_PENALTY_BLUE,
- }
- )
- _BALL_PLACEMENT_COMMANDS = frozenset(
- {
- RefereeCommand.BALL_PLACEMENT_YELLOW,
- RefereeCommand.BALL_PLACEMENT_BLUE,
- }
- )
-
- def step(
- self,
- current_time: float,
- violation: Optional[RuleViolation],
- game_frame: Optional["GameFrame"] = None,
- ) -> RefereeData:
- """Process one tick. Apply violation if not in cooldown. Return RefereeData."""
- if self.stage_start_time is None:
- self.stage_start_time = current_time
-
- if violation is not None and self._can_transition(current_time):
- self._apply_violation(violation, current_time)
-
- # ----------------------------------------------------------------
- # Auto-advance 1: STOP → next queued restart
- # Fires when all robots are ≥ _BALL_CLEAR_DIST from the ball.
- # ----------------------------------------------------------------
- if (
- self._auto_advance.stop_to_next_command
- and self.command == RefereeCommand.STOP
- and self.next_command in self._NEEDS_STOP_FIRST
- and game_frame is not None
- and self._all_robots_clear(game_frame)
- ):
- logger.info("All robots clear — auto-advancing STOP → %s", self.next_command.name)
- self.command = self.next_command
- self.command_counter += 1
- self.command_timestamp = current_time
- if self.command in self._BALL_PLACEMENT_COMMANDS:
- self.next_command = RefereeCommand.NORMAL_START
- self._advance4_ready_since = math.inf
- elif self.command in self._DIRECT_FREE_COMMANDS:
- self.next_command = RefereeCommand.NORMAL_START
- self._advance3_ready_since = math.inf
- elif self.command in self._PREPARE_KICKOFF_COMMANDS or self.command in self._PREPARE_PENALTY_COMMANDS:
- self.next_command = RefereeCommand.NORMAL_START
- self._prepare_entered_time = current_time
- self._advance2_ready_since = math.inf
- self._last_transition_time = current_time
-
- # ----------------------------------------------------------------
- # Auto-advance 2a: PREPARE_KICKOFF_* → NORMAL_START
- # Fires after prepare_duration_seconds AND one attacker is inside
- # the centre circle, sustained for _AUTO_ADVANCE_DELAY seconds.
- # ----------------------------------------------------------------
- elif self._auto_advance.prepare_kickoff_to_normal and self.command in self._PREPARE_KICKOFF_COMMANDS:
- ready = (
- (current_time - self._prepare_entered_time) >= self._prepare_duration_seconds
- and game_frame is not None
- and self._kicker_in_centre_circle(self.command, game_frame)
- )
- if ready:
- if self._advance2_ready_since == math.inf:
- self._advance2_ready_since = current_time
- logger.debug("Advance 2 countdown started (%s)", self.command.name)
- elif (current_time - self._advance2_ready_since) >= _AUTO_ADVANCE_DELAY:
- logger.info(
- "Kicker in centre circle — auto-advancing %s → NORMAL_START",
- self.command.name,
- )
- self.command = RefereeCommand.NORMAL_START
- self.command_counter += 1
- self.command_timestamp = current_time
- self.next_command = None
- self.status_message = None
- self._normal_start_time = current_time
- self._ball_pos_at_normal_start = (
- (game_frame.ball.p.x, game_frame.ball.p.y) if game_frame.ball is not None else None
- )
- self._advance2_ready_since = math.inf
- self._last_transition_time = current_time
- else:
- self._advance2_ready_since = math.inf
-
- # ----------------------------------------------------------------
- # Auto-advance 2b: PREPARE_PENALTY_* → NORMAL_START
- # Fires after prepare_duration_seconds when the kicker reaches the
- # penalty mark, sustained for _AUTO_ADVANCE_DELAY seconds.
- # ----------------------------------------------------------------
- elif self._auto_advance.prepare_penalty_to_normal and self.command in self._PREPARE_PENALTY_COMMANDS:
- ready = (
- (current_time - self._prepare_entered_time) >= self._prepare_duration_seconds
- and game_frame is not None
- and self._penalty_kicker_ready(self.command, game_frame)
- )
- if ready:
- if self._advance2_ready_since == math.inf:
- self._advance2_ready_since = current_time
- logger.debug("Advance 2 countdown started (%s)", self.command.name)
- elif (current_time - self._advance2_ready_since) >= _AUTO_ADVANCE_DELAY:
- logger.info(
- "Kicker at penalty mark — auto-advancing %s → NORMAL_START",
- self.command.name,
- )
- self.command = RefereeCommand.NORMAL_START
- self.command_counter += 1
- self.command_timestamp = current_time
- self.next_command = None
- self.status_message = None
- self._normal_start_time = current_time
- self._ball_pos_at_normal_start = (
- (game_frame.ball.p.x, game_frame.ball.p.y) if game_frame.ball is not None else None
- )
- self._advance2_ready_since = math.inf
- self._last_transition_time = current_time
- else:
- self._advance2_ready_since = math.inf
-
- # ----------------------------------------------------------------
- # Auto-advance 3: DIRECT_FREE_* → NORMAL_START
- # Fires when the kicker is within _KICKER_READY_DIST of the ball
- # AND all defending robots are ≥ _BALL_CLEAR_DIST away, sustained
- # for _AUTO_ADVANCE_DELAY seconds.
- # ----------------------------------------------------------------
- elif self._auto_advance.direct_free_to_normal and self.command in self._DIRECT_FREE_COMMANDS:
- ready = game_frame is not None and self._free_kick_ready(self.command, game_frame)
- if ready:
- if self._advance3_ready_since == math.inf:
- self._advance3_ready_since = current_time
- logger.debug("Advance 3 countdown started (%s)", self.command.name)
- elif (current_time - self._advance3_ready_since) >= _AUTO_ADVANCE_DELAY:
- logger.info("Free kick ready — auto-advancing %s → NORMAL_START", self.command.name)
- self.command = RefereeCommand.NORMAL_START
- self.command_counter += 1
- self.command_timestamp = current_time
- self.next_command = None
- self.status_message = None
- self._normal_start_time = current_time
- self._ball_pos_at_normal_start = (
- (game_frame.ball.p.x, game_frame.ball.p.y) if game_frame.ball is not None else None
- )
- self._advance3_ready_since = math.inf
- self._last_transition_time = current_time
- else:
- self._advance3_ready_since = math.inf
-
- # ----------------------------------------------------------------
- # Auto-advance 4: BALL_PLACEMENT_* → next_command
- # Fires when ball reaches within _PLACEMENT_DONE_DIST of target,
- # sustained for _AUTO_ADVANCE_DELAY seconds.
- # ----------------------------------------------------------------
- elif self._auto_advance.ball_placement_to_next and self.command in self._BALL_PLACEMENT_COMMANDS:
- ready = self.next_command is not None and game_frame is not None and self._ball_placement_done(game_frame)
- if ready:
- if self._advance4_ready_since == math.inf:
- self._advance4_ready_since = current_time
- logger.debug("Advance 4 countdown started (%s)", self.command.name)
- elif (current_time - self._advance4_ready_since) >= _AUTO_ADVANCE_DELAY:
- logger.info(
- "Ball placement complete — auto-advancing %s → %s",
- self.command.name,
- self.next_command.name,
- )
- self.command = self.next_command
- self.command_counter += 1
- self.command_timestamp = current_time
- self.next_command = None
- self._advance4_ready_since = math.inf
- self.status_message = None
- self._last_transition_time = current_time
- else:
- self._advance4_ready_since = math.inf
-
- # ----------------------------------------------------------------
- # Auto-advance 5: NORMAL_START → FORCE_START
- # Fires after kickoff_timeout_seconds if the ball hasn't moved ≥5 cm.
- # ----------------------------------------------------------------
- elif (
- self._auto_advance.normal_start_to_force
- and self.command == RefereeCommand.NORMAL_START
- and self._ball_pos_at_normal_start is not None
- and (current_time - self._normal_start_time) >= self._kickoff_timeout_seconds
- and game_frame is not None
- and game_frame.ball is not None
- and not self._ball_has_moved(game_frame)
- ):
- logger.info("Kickoff/free-kick timeout — auto-advancing NORMAL_START → FORCE_START")
- self.command = RefereeCommand.FORCE_START
- self.command_counter += 1
- self.command_timestamp = current_time
- self.next_command = None
- self.status_message = None
- self._ball_pos_at_normal_start = None
- self._last_transition_time = current_time
-
- # ----------------------------------------------------------------
- # Legacy force-start path: STOP → FORCE_START after goal
- # ----------------------------------------------------------------
- elif (
- self._force_start_after_goal
- and self.command == RefereeCommand.STOP
- and self.next_command in self._PREPARE_KICKOFF_COMMANDS
- and (current_time - self._stop_entered_time) >= self._stop_duration_seconds
- ):
- self.command = RefereeCommand.FORCE_START
- self.command_counter += 1
- self.command_timestamp = current_time
- self.next_command = None
- self.status_message = None
- self._last_transition_time = current_time
- logger.info("Auto-advanced STOP → FORCE_START after goal (force-start profile mode)")
-
- return self._generate_referee_data(current_time)
-
- def _all_robots_clear(self, game_frame: "GameFrame") -> bool:
- """Return True if every robot on both teams is ≥ _BALL_CLEAR_DIST from the ball."""
- ball = game_frame.ball
- if ball is None:
- return True
- bx, by = ball.p.x, ball.p.y
- for r in list(game_frame.friendly_robots.values()) + list(game_frame.enemy_robots.values()):
- if math.hypot(r.p.x - bx, r.p.y - by) < _BALL_CLEAR_DIST:
- return False
- return True
-
- def _kicker_in_centre_circle(self, command: RefereeCommand, game_frame: "GameFrame") -> bool:
- """Return True if at least one robot of the attacking team is inside the centre circle."""
- r = self._geometry.center_circle_radius if self._geometry is not None else 0.5 # fallback for standalone use
- kicking_is_yellow = command == RefereeCommand.PREPARE_KICKOFF_YELLOW
- attackers = (
- game_frame.friendly_robots if kicking_is_yellow == game_frame.my_team_is_yellow else game_frame.enemy_robots
- )
- return any(math.hypot(robot.p.x, robot.p.y) <= r for robot in attackers.values())
-
- def _penalty_kicker_ready(self, command: RefereeCommand, game_frame: "GameFrame") -> bool:
- """Return True when an attacking robot is within the ready radius of the penalty mark."""
- kicking_is_yellow = command == RefereeCommand.PREPARE_PENALTY_YELLOW
- attackers = (
- game_frame.friendly_robots if kicking_is_yellow == game_frame.my_team_is_yellow else game_frame.enemy_robots
- )
- if not attackers:
- return False
-
- half_length = (
- self._geometry.half_length if self._geometry is not None else STANDARD_FIELD_DIMS.full_field_half_length
- )
- yellow_is_right = game_frame.my_team_is_right == game_frame.my_team_is_yellow
- if kicking_is_yellow:
- goal_sign = -1.0 if yellow_is_right else 1.0
- else:
- goal_sign = 1.0 if yellow_is_right else -1.0
- penalty_mark_x = goal_sign * half_length * 0.5
-
- closest = min(math.hypot(robot.p.x - penalty_mark_x, robot.p.y) for robot in attackers.values())
- return closest <= _KICKER_READY_DIST
-
- def _free_kick_ready(self, command: RefereeCommand, game_frame: "GameFrame") -> bool:
- """Return True when a free kick is ready to start:
- - The kicker (closest attacker to ball) is within _KICKER_READY_DIST of the ball.
- - All defending robots are ≥ _BALL_CLEAR_DIST from the ball.
- """
- ball = game_frame.ball
- if ball is None:
- return False
- bx, by = ball.p.x, ball.p.y
-
- kicking_is_yellow = command == RefereeCommand.DIRECT_FREE_YELLOW
- attackers = (
- game_frame.friendly_robots if kicking_is_yellow == game_frame.my_team_is_yellow else game_frame.enemy_robots
- )
- defenders = (
- game_frame.enemy_robots if kicking_is_yellow == game_frame.my_team_is_yellow else game_frame.friendly_robots
- )
-
- # Check defending robots are all clear.
- if any(math.hypot(r.p.x - bx, r.p.y - by) < _BALL_CLEAR_DIST for r in defenders.values()):
- return False
-
- # Check at least one attacker is close to the ball (kicker in position).
- if not attackers:
- return False
- closest = min(math.hypot(r.p.x - bx, r.p.y - by) for r in attackers.values())
- return closest <= _KICKER_READY_DIST
-
- def _ball_has_moved(self, game_frame: "GameFrame") -> bool:
- """Return True if the ball has moved ≥ 0.05 m since NORMAL_START."""
- if self._ball_pos_at_normal_start is None or game_frame.ball is None:
- return False
- ox, oy = self._ball_pos_at_normal_start
- return math.hypot(game_frame.ball.p.x - ox, game_frame.ball.p.y - oy) >= 0.05
-
- def _ball_placement_done(self, game_frame: "GameFrame") -> bool:
- """Return True when the ball is within _PLACEMENT_DONE_DIST of the placement target."""
- if self.ball_placement_target is None or game_frame.ball is None:
- return False
- tx, ty = self.ball_placement_target
- return math.hypot(game_frame.ball.p.x - tx, game_frame.ball.p.y - ty) <= _PLACEMENT_DONE_DIST
-
- # Commands that require robots to clear the ball before they take effect.
- # In a real match these are always preceded by STOP.
- _NEEDS_STOP_FIRST = frozenset(
- {
- RefereeCommand.PREPARE_KICKOFF_YELLOW,
- RefereeCommand.PREPARE_KICKOFF_BLUE,
- RefereeCommand.DIRECT_FREE_YELLOW,
- RefereeCommand.DIRECT_FREE_BLUE,
- RefereeCommand.PREPARE_PENALTY_YELLOW,
- RefereeCommand.PREPARE_PENALTY_BLUE,
- RefereeCommand.BALL_PLACEMENT_YELLOW,
- RefereeCommand.BALL_PLACEMENT_BLUE,
- }
- )
-
- def seed_clock(self, timestamp: float) -> None:
- """Align all internal timers to *timestamp*.
-
- Resets every timer that was initialised before the real vision clock
- was known (construction time) so that durations are measured from the
- correct timebase. Safe to call only once, immediately after the first
- valid game frame is available.
- """
- if self.stage_start_time is None:
- self.stage_start_time = timestamp
- self.command_timestamp = timestamp
- # Push all "entered-at" sentinels to the new timebase so that
- # cooldowns / durations are not immediately satisfied.
- self._last_transition_time = timestamp - _TRANSITION_COOLDOWN # allow transitions immediately
- self._stop_entered_time = timestamp - self._stop_duration_seconds # won't auto-advance yet
- self._prepare_entered_time = timestamp - self._prepare_duration_seconds # same
-
- def set_command(self, command: RefereeCommand, timestamp: float) -> None:
- """Manual override — for operator use or test scripting.
-
- If *command* is a set-piece command (kickoff, free kick, penalty,
- ball placement) and the game is not already in STOP or HALT, a STOP
- is issued first and the requested command is stored as ``next_command``
- so the operator (or a script) can advance to it after robots have
- cleared the ball. This mirrors real-match game-controller behaviour
- and prevents robots from receiving a PREPARE_KICKOFF while they are
- still within the keep-out zone around the ball.
- """
- _ALREADY_STOPPED = (RefereeCommand.STOP, RefereeCommand.HALT)
-
- if command in self._NEEDS_STOP_FIRST and self.command not in _ALREADY_STOPPED:
- # Insert STOP; park the real command as next_command.
- logger.info("Inserting STOP before %s so robots can clear the ball", command.name)
- self.command = RefereeCommand.STOP
- self.command_counter += 1
- self.command_timestamp = timestamp
- self.next_command = command
- self.status_message = None
- self._stop_entered_time = timestamp
- return
-
- # NORMAL_START while in STOP with a pending set-piece: advance to the
- # set-piece first so robots can form up. Auto-advance will then issue
- # NORMAL_START after prepare_duration_seconds.
- if (
- command == RefereeCommand.NORMAL_START
- and self.command == RefereeCommand.STOP
- and self.next_command in self._NEEDS_STOP_FIRST
- ):
- logger.info(
- "Manually advancing STOP → %s (auto NORMAL_START in %.1f s)",
- self.next_command.name,
- self._prepare_duration_seconds,
- )
- self.command = self.next_command
- self.command_counter += 1
- self.command_timestamp = timestamp
- self.next_command = RefereeCommand.NORMAL_START
- self.status_message = None
- self._prepare_entered_time = timestamp
- return
-
- self.command = command
- self.command_counter += 1
- self.command_timestamp = timestamp
- self.status_message = None
- self._advance2_ready_since = math.inf
- self._advance3_ready_since = math.inf
- self._advance4_ready_since = math.inf
-
- if command in (RefereeCommand.STOP, RefereeCommand.HALT):
- self._stop_entered_time = timestamp
- elif command in (
- RefereeCommand.PREPARE_KICKOFF_YELLOW,
- RefereeCommand.PREPARE_KICKOFF_BLUE,
- RefereeCommand.PREPARE_PENALTY_YELLOW,
- RefereeCommand.PREPARE_PENALTY_BLUE,
- ):
- self._prepare_entered_time = timestamp
-
- # Advance PRE stages to their active counterpart when play begins.
- _PRE_TO_ACTIVE = {
- Stage.NORMAL_FIRST_HALF_PRE: Stage.NORMAL_FIRST_HALF,
- Stage.NORMAL_SECOND_HALF_PRE: Stage.NORMAL_SECOND_HALF,
- Stage.EXTRA_FIRST_HALF_PRE: Stage.EXTRA_FIRST_HALF,
- Stage.EXTRA_SECOND_HALF_PRE: Stage.EXTRA_SECOND_HALF,
- }
- if command in (RefereeCommand.NORMAL_START, RefereeCommand.FORCE_START):
- active = _PRE_TO_ACTIVE.get(self.stage)
- if active is not None:
- self.advance_stage(active, timestamp)
-
- logger.info("Referee command manually set to: %s", command.name)
-
- def advance_stage(self, new_stage: Stage, timestamp: float) -> None:
- """Advance the game stage."""
- logger.info("Stage %s → %s", self.stage.name, new_stage.name)
- self.stage = new_stage
- self.stage_start_time = timestamp
-
- # ------------------------------------------------------------------
- # Internal helpers
- # ------------------------------------------------------------------
-
- def _can_transition(self, current_time: float) -> bool:
- return (current_time - self._last_transition_time) >= _TRANSITION_COOLDOWN
-
- def _apply_violation(self, violation: RuleViolation, current_time: float) -> None:
- """Update state in response to a detected violation."""
- if violation.rule_name == "goal":
- self._handle_goal(violation, current_time)
- else:
- self._handle_foul(violation, current_time)
-
- self._last_transition_time = current_time
-
- def _handle_goal(self, violation: RuleViolation, current_time: float) -> None:
- # Determine scorer from next_command (loser gets the kickoff).
- if violation.next_command == RefereeCommand.PREPARE_KICKOFF_BLUE:
- # Blue gets kickoff → yellow scored.
- self.yellow_team.increment_score()
- logger.info(
- "Goal by Yellow! Score: Yellow %d – Blue %d",
- self.yellow_team.score,
- self.blue_team.score,
- )
- elif violation.next_command == RefereeCommand.PREPARE_KICKOFF_YELLOW:
- # Yellow gets kickoff → blue scored.
- self.blue_team.increment_score()
- logger.info(
- "Goal by Blue! Score: Yellow %d – Blue %d",
- self.yellow_team.score,
- self.blue_team.score,
- )
-
- self.command = RefereeCommand.STOP
- self.command_counter += 1
- self.command_timestamp = current_time
- self.next_command = violation.next_command
- self.ball_placement_target = (0.0, 0.0)
- self.status_message = violation.status_message
- self._stop_entered_time = current_time
-
- def _handle_foul(self, violation: RuleViolation, current_time: float) -> None:
- self.command = violation.suggested_command
- self.command_counter += 1
- self.command_timestamp = current_time
- self.next_command = violation.next_command
- self.ball_placement_target = violation.designated_position
- self.status_message = violation.status_message
- logger.info(
- "Foul detected: %s → %s (next: %s)",
- violation.rule_name,
- violation.suggested_command.name,
- violation.next_command.name if violation.next_command else "None",
- )
-
- def _generate_referee_data(self, current_time: float) -> RefereeData:
- stage_time_left = max(0.0, self.stage_duration - (current_time - self.stage_start_time))
- return RefereeData(
- source_identifier="custom_referee",
- time_sent=current_time,
- time_received=current_time,
- referee_command=self.command,
- referee_command_timestamp=self.command_timestamp,
- stage=self.stage,
- stage_time_left=stage_time_left,
- blue_team=copy.copy(self.blue_team),
- yellow_team=copy.copy(self.yellow_team),
- designated_position=self.ball_placement_target,
- blue_team_on_positive_half=None,
- next_command=self.next_command,
- current_action_time_remaining=None,
- status_message=self.status_message,
- )
+"""GameStateMachine: owns all mutable game state for the CustomReferee."""
+
+from __future__ import annotations
+
+import copy
+import logging
+import math
+from typing import Optional
+
+from utama_core.config.field_params import STANDARD_FIELD_DIMS
+from utama_core.custom_referee.geometry import RefereeGeometry
+from utama_core.custom_referee.profiles.profile_loader import AutoAdvanceConfig
+from utama_core.custom_referee.rules.base_rule import RuleViolation
+from utama_core.entities.data.referee import RefereeData
+from utama_core.entities.game.game_frame import GameFrame
+from utama_core.entities.game.team_info import TeamInfo
+from utama_core.entities.referee.referee_command import RefereeCommand
+from utama_core.entities.referee.stage import Stage
+
+logger = logging.getLogger(__name__)
+
+_TRANSITION_COOLDOWN = 0.3 # seconds — prevents command oscillation
+_BALL_CLEAR_DIST = 0.5 # metres — all robots must be this far from ball before queued restart
+_KICKER_READY_DIST = 0.3 # metres — kicker must be within this distance to trigger free kick start
+_PLACEMENT_DONE_DIST = 0.15 # metres — ball within this dist of target → placement complete
+_AUTO_ADVANCE_DELAY = 2.0 # seconds — readiness must be sustained this long before play starts
+
+
+class GameStateMachine:
+ """Owns score, command, and stage. Produces ``RefereeData`` each tick."""
+
+ def __init__(
+ self,
+ half_duration_seconds: float,
+ kickoff_team: str,
+ n_robots_yellow: int,
+ n_robots_blue: int,
+ initial_stage: Stage = Stage.NORMAL_FIRST_HALF_PRE,
+ force_start_after_goal: bool = False,
+ stop_duration_seconds: float = 3.0,
+ prepare_duration_seconds: float = 3.0,
+ kickoff_timeout_seconds: float = 10.0,
+ geometry: Optional[RefereeGeometry] = None,
+ auto_advance: Optional[AutoAdvanceConfig] = None,
+ ) -> None:
+ self.command = RefereeCommand.HALT
+ self.command_counter = 0
+ self.command_timestamp = 0.0
+
+ self.stage = initial_stage
+ # Seeded by seed_clock() after the first valid game frame is available.
+ self.stage_start_time: Optional[float] = None
+ self.stage_duration = half_duration_seconds
+
+ self.yellow_team = TeamInfo(
+ name="Yellow",
+ score=0,
+ red_cards=0,
+ yellow_card_times=[],
+ yellow_cards=0,
+ timeouts=4,
+ timeout_time=300,
+ goalkeeper=0,
+ foul_counter=0,
+ ball_placement_failures=0,
+ can_place_ball=True,
+ max_allowed_bots=n_robots_yellow,
+ bot_substitution_intent=False,
+ bot_substitution_allowed=True,
+ bot_substitutions_left=5,
+ )
+ self.blue_team = TeamInfo(
+ name="Blue",
+ score=0,
+ red_cards=0,
+ yellow_card_times=[],
+ yellow_cards=0,
+ timeouts=4,
+ timeout_time=300,
+ goalkeeper=0,
+ foul_counter=0,
+ ball_placement_failures=0,
+ can_place_ball=True,
+ max_allowed_bots=n_robots_blue,
+ bot_substitution_intent=False,
+ bot_substitution_allowed=True,
+ bot_substitutions_left=5,
+ )
+
+ self.next_command: Optional[RefereeCommand] = None
+ self.ball_placement_target: Optional[tuple[float, float]] = None
+ self._post_ball_placement_command: Optional[RefereeCommand] = None
+ self.status_message: Optional[str] = None
+
+ # Kickoff team initialised from profile.
+ self._kickoff_team_is_yellow = kickoff_team.lower() == "yellow"
+
+ # Arcade auto-advance: after stop_duration_seconds in STOP following a
+ # goal, automatically issue FORCE_START instead of waiting for operator.
+ self._force_start_after_goal = force_start_after_goal
+ self._stop_duration_seconds = stop_duration_seconds
+ self._stop_entered_time: float = -math.inf # wall time when STOP was last entered
+
+ # Auto-advance timings.
+ self._prepare_duration_seconds = prepare_duration_seconds
+ self._kickoff_timeout_seconds = kickoff_timeout_seconds
+ self._prepare_entered_time: float = -math.inf # wall time when PREPARE_KICKOFF was entered
+ self._normal_start_time: float = -math.inf # wall time when NORMAL_START was entered
+
+ # Ball position snapshot at NORMAL_START — used to detect if the ball has moved.
+ self._ball_pos_at_normal_start: Optional[tuple[float, float]] = None
+
+ # Timestamps for sustained-readiness countdown before play-starting advances.
+ # Set to math.inf when condition is not yet met; fire when elapsed >= _AUTO_ADVANCE_DELAY.
+ self._advance2_ready_since: float = math.inf # PREPARE_* → NORMAL_START
+ self._advance3_ready_since: float = math.inf # DIRECT_FREE_* → NORMAL_START
+ self._advance4_ready_since: float = math.inf # BALL_PLACEMENT_* → next_command
+
+ # Field geometry (used for readiness checks).
+ self._geometry: Optional[RefereeGeometry] = geometry
+
+ # Cooldown: don't process a new violation within this window.
+ self._last_transition_time: float = -math.inf
+
+ # Per-transition enable flags (default: all on).
+ self._auto_advance = auto_advance if auto_advance is not None else AutoAdvanceConfig()
+
+ # ------------------------------------------------------------------
+ # Public API
+ # ------------------------------------------------------------------
+
+ _PREPARE_KICKOFF_COMMANDS = frozenset(
+ {
+ RefereeCommand.PREPARE_KICKOFF_YELLOW,
+ RefereeCommand.PREPARE_KICKOFF_BLUE,
+ }
+ )
+ _DIRECT_FREE_COMMANDS = frozenset(
+ {
+ RefereeCommand.DIRECT_FREE_YELLOW,
+ RefereeCommand.DIRECT_FREE_BLUE,
+ }
+ )
+ _PREPARE_PENALTY_COMMANDS = frozenset(
+ {
+ RefereeCommand.PREPARE_PENALTY_YELLOW,
+ RefereeCommand.PREPARE_PENALTY_BLUE,
+ }
+ )
+ _BALL_PLACEMENT_COMMANDS = frozenset(
+ {
+ RefereeCommand.BALL_PLACEMENT_YELLOW,
+ RefereeCommand.BALL_PLACEMENT_BLUE,
+ }
+ )
+
+ def step(
+ self,
+ current_time: float,
+ violation: Optional[RuleViolation],
+ game_frame: Optional["GameFrame"] = None,
+ ) -> RefereeData:
+ """Process one tick. Apply violation if not in cooldown. Return RefereeData."""
+ if self.stage_start_time is None:
+ self.stage_start_time = current_time
+
+ if violation is not None and self._can_transition(current_time):
+ self._apply_violation(violation, current_time)
+
+ # ----------------------------------------------------------------
+ # Auto-advance 1: STOP → next queued restart
+ # Fires when all robots are ≥ _BALL_CLEAR_DIST from the ball.
+ # ----------------------------------------------------------------
+ if (
+ self._auto_advance.stop_to_next_command
+ and self.command == RefereeCommand.STOP
+ and self.next_command in self._NEEDS_STOP_FIRST
+ and game_frame is not None
+ and self._all_robots_clear(game_frame)
+ ):
+ logger.info("All robots clear — auto-advancing STOP → %s", self.next_command.name)
+ self.command = self.next_command
+ self.command_counter += 1
+ self.command_timestamp = current_time
+ if self.command in self._BALL_PLACEMENT_COMMANDS:
+ self.next_command = self._post_ball_placement_command or RefereeCommand.NORMAL_START
+ self._post_ball_placement_command = None
+ self._advance4_ready_since = math.inf
+ elif self.command in self._DIRECT_FREE_COMMANDS:
+ self.next_command = RefereeCommand.NORMAL_START
+ self._advance3_ready_since = math.inf
+ elif self.command in self._PREPARE_KICKOFF_COMMANDS or self.command in self._PREPARE_PENALTY_COMMANDS:
+ self.next_command = RefereeCommand.NORMAL_START
+ self._prepare_entered_time = current_time
+ self._advance2_ready_since = math.inf
+ self._last_transition_time = current_time
+
+ # ----------------------------------------------------------------
+ # Auto-advance 2a: PREPARE_KICKOFF_* → NORMAL_START
+ # Fires after prepare_duration_seconds AND one attacker is inside
+ # the centre circle, sustained for _AUTO_ADVANCE_DELAY seconds.
+ # ----------------------------------------------------------------
+ elif self._auto_advance.prepare_kickoff_to_normal and self.command in self._PREPARE_KICKOFF_COMMANDS:
+ ready = (
+ (current_time - self._prepare_entered_time) >= self._prepare_duration_seconds
+ and game_frame is not None
+ and self._kicker_in_centre_circle(self.command, game_frame)
+ )
+ if ready:
+ if self._advance2_ready_since == math.inf:
+ self._advance2_ready_since = current_time
+ logger.debug("Advance 2 countdown started (%s)", self.command.name)
+ elif (current_time - self._advance2_ready_since) >= _AUTO_ADVANCE_DELAY:
+ logger.info(
+ "Kicker in centre circle — auto-advancing %s → NORMAL_START",
+ self.command.name,
+ )
+ self.command = RefereeCommand.NORMAL_START
+ self.command_counter += 1
+ self.command_timestamp = current_time
+ self.next_command = None
+ self.status_message = None
+ self._normal_start_time = current_time
+ self._ball_pos_at_normal_start = (
+ (game_frame.ball.p.x, game_frame.ball.p.y) if game_frame.ball is not None else None
+ )
+ self._advance2_ready_since = math.inf
+ self._last_transition_time = current_time
+ else:
+ self._advance2_ready_since = math.inf
+
+ # ----------------------------------------------------------------
+ # Auto-advance 2b: PREPARE_PENALTY_* → NORMAL_START
+ # Fires after prepare_duration_seconds when the kicker reaches the
+ # penalty mark, sustained for _AUTO_ADVANCE_DELAY seconds.
+ # ----------------------------------------------------------------
+ elif self._auto_advance.prepare_penalty_to_normal and self.command in self._PREPARE_PENALTY_COMMANDS:
+ ready = (
+ (current_time - self._prepare_entered_time) >= self._prepare_duration_seconds
+ and game_frame is not None
+ and self._penalty_kicker_ready(self.command, game_frame)
+ )
+ if ready:
+ if self._advance2_ready_since == math.inf:
+ self._advance2_ready_since = current_time
+ logger.debug("Advance 2 countdown started (%s)", self.command.name)
+ elif (current_time - self._advance2_ready_since) >= _AUTO_ADVANCE_DELAY:
+ logger.info(
+ "Kicker at penalty mark — auto-advancing %s → NORMAL_START",
+ self.command.name,
+ )
+ self.command = RefereeCommand.NORMAL_START
+ self.command_counter += 1
+ self.command_timestamp = current_time
+ self.next_command = None
+ self.status_message = None
+ self._normal_start_time = current_time
+ self._ball_pos_at_normal_start = (
+ (game_frame.ball.p.x, game_frame.ball.p.y) if game_frame.ball is not None else None
+ )
+ self._advance2_ready_since = math.inf
+ self._last_transition_time = current_time
+ else:
+ self._advance2_ready_since = math.inf
+
+ # ----------------------------------------------------------------
+ # Auto-advance 3: DIRECT_FREE_* → NORMAL_START
+ # Fires when the kicker is within _KICKER_READY_DIST of the ball
+ # AND all defending robots are ≥ _BALL_CLEAR_DIST away, sustained
+ # for _AUTO_ADVANCE_DELAY seconds.
+ # ----------------------------------------------------------------
+ elif self._auto_advance.direct_free_to_normal and self.command in self._DIRECT_FREE_COMMANDS:
+ ready = game_frame is not None and self._free_kick_ready(self.command, game_frame)
+ if ready:
+ if self._advance3_ready_since == math.inf:
+ self._advance3_ready_since = current_time
+ logger.debug("Advance 3 countdown started (%s)", self.command.name)
+ elif (current_time - self._advance3_ready_since) >= _AUTO_ADVANCE_DELAY:
+ logger.info("Free kick ready — auto-advancing %s → NORMAL_START", self.command.name)
+ self.command = RefereeCommand.NORMAL_START
+ self.command_counter += 1
+ self.command_timestamp = current_time
+ self.next_command = None
+ self.status_message = None
+ self._normal_start_time = current_time
+ self._ball_pos_at_normal_start = (
+ (game_frame.ball.p.x, game_frame.ball.p.y) if game_frame.ball is not None else None
+ )
+ self._advance3_ready_since = math.inf
+ self._last_transition_time = current_time
+ else:
+ self._advance3_ready_since = math.inf
+
+ # ----------------------------------------------------------------
+ # Auto-advance 4: BALL_PLACEMENT_* → next_command
+ # Fires when ball reaches within _PLACEMENT_DONE_DIST of target,
+ # sustained for _AUTO_ADVANCE_DELAY seconds.
+ # ----------------------------------------------------------------
+ elif self._auto_advance.ball_placement_to_next and self.command in self._BALL_PLACEMENT_COMMANDS:
+ ready = self.next_command is not None and game_frame is not None and self._ball_placement_done(game_frame)
+ if ready:
+ if self._advance4_ready_since == math.inf:
+ self._advance4_ready_since = current_time
+ logger.debug("Advance 4 countdown started (%s)", self.command.name)
+ elif (current_time - self._advance4_ready_since) >= _AUTO_ADVANCE_DELAY:
+ logger.info(
+ "Ball placement complete — auto-advancing %s → %s",
+ self.command.name,
+ self.next_command.name,
+ )
+ completed_command = self.next_command
+ self.command = completed_command
+ self.command_counter += 1
+ self.command_timestamp = current_time
+ if completed_command in self._DIRECT_FREE_COMMANDS:
+ self.next_command = RefereeCommand.NORMAL_START
+ self._advance3_ready_since = math.inf
+ elif (
+ completed_command in self._PREPARE_KICKOFF_COMMANDS
+ or completed_command in self._PREPARE_PENALTY_COMMANDS
+ ):
+ self.next_command = RefereeCommand.NORMAL_START
+ self._prepare_entered_time = current_time
+ self._advance2_ready_since = math.inf
+ else:
+ self.next_command = None
+ self._advance4_ready_since = math.inf
+ self.status_message = None
+ self._last_transition_time = current_time
+ else:
+ self._advance4_ready_since = math.inf
+
+ # ----------------------------------------------------------------
+ # Auto-advance 5: NORMAL_START → FORCE_START
+ # Fires after kickoff_timeout_seconds if the ball hasn't moved ≥5 cm.
+ # ----------------------------------------------------------------
+ elif (
+ self._auto_advance.normal_start_to_force
+ and self.command == RefereeCommand.NORMAL_START
+ and self._ball_pos_at_normal_start is not None
+ and (current_time - self._normal_start_time) >= self._kickoff_timeout_seconds
+ and game_frame is not None
+ and game_frame.ball is not None
+ and not self._ball_has_moved(game_frame)
+ ):
+ logger.info("Kickoff/free-kick timeout — auto-advancing NORMAL_START → FORCE_START")
+ self.command = RefereeCommand.FORCE_START
+ self.command_counter += 1
+ self.command_timestamp = current_time
+ self.next_command = None
+ self.status_message = None
+ self._ball_pos_at_normal_start = None
+ self._last_transition_time = current_time
+
+ # ----------------------------------------------------------------
+ # Legacy force-start path: STOP → FORCE_START after goal
+ # ----------------------------------------------------------------
+ elif (
+ self._force_start_after_goal
+ and self.command == RefereeCommand.STOP
+ and self.next_command in self._PREPARE_KICKOFF_COMMANDS
+ and (current_time - self._stop_entered_time) >= self._stop_duration_seconds
+ ):
+ self.command = RefereeCommand.FORCE_START
+ self.command_counter += 1
+ self.command_timestamp = current_time
+ self.next_command = None
+ self.status_message = None
+ self._last_transition_time = current_time
+ logger.info("Auto-advanced STOP → FORCE_START after goal (force-start profile mode)")
+
+ return self._generate_referee_data(current_time)
+
+ def _all_robots_clear(self, game_frame: "GameFrame") -> bool:
+ """Return True if every robot on both teams is ≥ _BALL_CLEAR_DIST from the ball."""
+ ball = game_frame.ball
+ if ball is None:
+ return True
+ bx, by = ball.p.x, ball.p.y
+ for r in list(game_frame.friendly_robots.values()) + list(game_frame.enemy_robots.values()):
+ if math.hypot(r.p.x - bx, r.p.y - by) < _BALL_CLEAR_DIST:
+ return False
+ return True
+
+ def _kicker_in_centre_circle(self, command: RefereeCommand, game_frame: "GameFrame") -> bool:
+ """Return True if at least one robot of the attacking team is inside the centre circle."""
+ r = self._geometry.center_circle_radius if self._geometry is not None else 0.5 # fallback for standalone use
+ kicking_is_yellow = command == RefereeCommand.PREPARE_KICKOFF_YELLOW
+ attackers = (
+ game_frame.friendly_robots if kicking_is_yellow == game_frame.my_team_is_yellow else game_frame.enemy_robots
+ )
+ return any(math.hypot(robot.p.x, robot.p.y) <= r for robot in attackers.values())
+
+ def _penalty_kicker_ready(self, command: RefereeCommand, game_frame: "GameFrame") -> bool:
+ """Return True when an attacking robot is within the ready radius of the penalty mark."""
+ kicking_is_yellow = command == RefereeCommand.PREPARE_PENALTY_YELLOW
+ attackers = (
+ game_frame.friendly_robots if kicking_is_yellow == game_frame.my_team_is_yellow else game_frame.enemy_robots
+ )
+ if not attackers:
+ return False
+
+ half_length = (
+ self._geometry.half_length if self._geometry is not None else STANDARD_FIELD_DIMS.full_field_half_length
+ )
+ yellow_is_right = game_frame.my_team_is_right == game_frame.my_team_is_yellow
+ if kicking_is_yellow:
+ goal_sign = -1.0 if yellow_is_right else 1.0
+ else:
+ goal_sign = 1.0 if yellow_is_right else -1.0
+ penalty_mark_x = goal_sign * half_length * 0.5
+
+ closest = min(math.hypot(robot.p.x - penalty_mark_x, robot.p.y) for robot in attackers.values())
+ return closest <= _KICKER_READY_DIST
+
+ def _free_kick_ready(self, command: RefereeCommand, game_frame: "GameFrame") -> bool:
+ """Return True when a free kick is ready to start:
+ - The kicker (closest attacker to ball) is within _KICKER_READY_DIST of the ball.
+ - All defending robots are ≥ _BALL_CLEAR_DIST from the ball.
+ """
+ ball = game_frame.ball
+ if ball is None:
+ return False
+ bx, by = ball.p.x, ball.p.y
+
+ kicking_is_yellow = command == RefereeCommand.DIRECT_FREE_YELLOW
+ attackers = (
+ game_frame.friendly_robots if kicking_is_yellow == game_frame.my_team_is_yellow else game_frame.enemy_robots
+ )
+ defenders = (
+ game_frame.enemy_robots if kicking_is_yellow == game_frame.my_team_is_yellow else game_frame.friendly_robots
+ )
+
+ # Check defending robots are all clear.
+ if any(math.hypot(r.p.x - bx, r.p.y - by) < _BALL_CLEAR_DIST for r in defenders.values()):
+ return False
+
+ # Check at least one attacker is close to the ball (kicker in position).
+ if not attackers:
+ return False
+ closest = min(math.hypot(r.p.x - bx, r.p.y - by) for r in attackers.values())
+ return closest <= _KICKER_READY_DIST
+
+ def _ball_has_moved(self, game_frame: "GameFrame") -> bool:
+ """Return True if the ball has moved ≥ 0.05 m since NORMAL_START."""
+ if self._ball_pos_at_normal_start is None or game_frame.ball is None:
+ return False
+ ox, oy = self._ball_pos_at_normal_start
+ return math.hypot(game_frame.ball.p.x - ox, game_frame.ball.p.y - oy) >= 0.05
+
+ def _ball_placement_done(self, game_frame: "GameFrame") -> bool:
+ """Return True when the ball is within _PLACEMENT_DONE_DIST of the placement target."""
+ if self.ball_placement_target is None or game_frame.ball is None:
+ return False
+ tx, ty = self.ball_placement_target
+ return math.hypot(game_frame.ball.p.x - tx, game_frame.ball.p.y - ty) <= _PLACEMENT_DONE_DIST
+
+ # Commands that require robots to clear the ball before they take effect.
+ # In a real match these are always preceded by STOP.
+ _NEEDS_STOP_FIRST = frozenset(
+ {
+ RefereeCommand.PREPARE_KICKOFF_YELLOW,
+ RefereeCommand.PREPARE_KICKOFF_BLUE,
+ RefereeCommand.DIRECT_FREE_YELLOW,
+ RefereeCommand.DIRECT_FREE_BLUE,
+ RefereeCommand.PREPARE_PENALTY_YELLOW,
+ RefereeCommand.PREPARE_PENALTY_BLUE,
+ RefereeCommand.BALL_PLACEMENT_YELLOW,
+ RefereeCommand.BALL_PLACEMENT_BLUE,
+ }
+ )
+
+ @staticmethod
+ def _ball_placement_command_for(restart_command: Optional[RefereeCommand]) -> Optional[RefereeCommand]:
+ """Return the ball-placement command for the team that owns a restart."""
+ if restart_command in (
+ RefereeCommand.PREPARE_KICKOFF_YELLOW,
+ RefereeCommand.PREPARE_PENALTY_YELLOW,
+ RefereeCommand.DIRECT_FREE_YELLOW,
+ RefereeCommand.INDIRECT_FREE_YELLOW,
+ ):
+ return RefereeCommand.BALL_PLACEMENT_YELLOW
+ if restart_command in (
+ RefereeCommand.PREPARE_KICKOFF_BLUE,
+ RefereeCommand.PREPARE_PENALTY_BLUE,
+ RefereeCommand.DIRECT_FREE_BLUE,
+ RefereeCommand.INDIRECT_FREE_BLUE,
+ ):
+ return RefereeCommand.BALL_PLACEMENT_BLUE
+ return None
+
+ def seed_clock(self, timestamp: float) -> None:
+ """Align all internal timers to *timestamp*.
+
+ Resets every timer that was initialised before the real vision clock
+ was known (construction time) so that durations are measured from the
+ correct timebase. Safe to call only once, immediately after the first
+ valid game frame is available.
+ """
+ if self.stage_start_time is None:
+ self.stage_start_time = timestamp
+ self.command_timestamp = timestamp
+ # Push all "entered-at" sentinels to the new timebase so that
+ # cooldowns / durations are not immediately satisfied.
+ self._last_transition_time = timestamp - _TRANSITION_COOLDOWN # allow transitions immediately
+ self._stop_entered_time = timestamp - self._stop_duration_seconds # won't auto-advance yet
+ self._prepare_entered_time = timestamp - self._prepare_duration_seconds # same
+
+ def set_command(self, command: RefereeCommand, timestamp: float) -> None:
+ """Manual override — for operator use or test scripting.
+
+ If *command* is a set-piece command (kickoff, free kick, penalty,
+ ball placement) and the game is not already in STOP or HALT, a STOP
+ is issued first and the requested command is stored as ``next_command``
+ so the operator (or a script) can advance to it after robots have
+ cleared the ball. This mirrors real-match game-controller behaviour
+ and prevents robots from receiving a PREPARE_KICKOFF while they are
+ still within the keep-out zone around the ball.
+ """
+ _ALREADY_STOPPED = (RefereeCommand.STOP, RefereeCommand.HALT)
+
+ if command in self._NEEDS_STOP_FIRST and self.command not in _ALREADY_STOPPED:
+ # Insert STOP; park the real command as next_command.
+ logger.info("Inserting STOP before %s so robots can clear the ball", command.name)
+ self.command = RefereeCommand.STOP
+ self.command_counter += 1
+ self.command_timestamp = timestamp
+ self.next_command = command
+ self._post_ball_placement_command = None
+ self.status_message = None
+ self._stop_entered_time = timestamp
+ return
+
+ # NORMAL_START while in STOP with a pending set-piece: advance to the
+ # set-piece first so robots can form up. Auto-advance will then issue
+ # NORMAL_START after prepare_duration_seconds.
+ if (
+ command == RefereeCommand.NORMAL_START
+ and self.command == RefereeCommand.STOP
+ and self.next_command in self._NEEDS_STOP_FIRST
+ ):
+ logger.info(
+ "Manually advancing STOP → %s (auto NORMAL_START in %.1f s)",
+ self.next_command.name,
+ self._prepare_duration_seconds,
+ )
+ self.command = self.next_command
+ self.command_counter += 1
+ self.command_timestamp = timestamp
+ if self.command in self._BALL_PLACEMENT_COMMANDS:
+ self.next_command = self._post_ball_placement_command or RefereeCommand.NORMAL_START
+ self._post_ball_placement_command = None
+ self._advance4_ready_since = math.inf
+ else:
+ self.next_command = RefereeCommand.NORMAL_START
+ self.status_message = None
+ self._prepare_entered_time = timestamp
+ return
+
+ self.command = command
+ self.command_counter += 1
+ self.command_timestamp = timestamp
+ self._post_ball_placement_command = None
+ self.status_message = None
+ self._advance2_ready_since = math.inf
+ self._advance3_ready_since = math.inf
+ self._advance4_ready_since = math.inf
+
+ if command in (RefereeCommand.STOP, RefereeCommand.HALT):
+ self._stop_entered_time = timestamp
+ elif command in (
+ RefereeCommand.PREPARE_KICKOFF_YELLOW,
+ RefereeCommand.PREPARE_KICKOFF_BLUE,
+ RefereeCommand.PREPARE_PENALTY_YELLOW,
+ RefereeCommand.PREPARE_PENALTY_BLUE,
+ ):
+ self._prepare_entered_time = timestamp
+
+ # Advance PRE stages to their active counterpart when play begins.
+ _PRE_TO_ACTIVE = {
+ Stage.NORMAL_FIRST_HALF_PRE: Stage.NORMAL_FIRST_HALF,
+ Stage.NORMAL_SECOND_HALF_PRE: Stage.NORMAL_SECOND_HALF,
+ Stage.EXTRA_FIRST_HALF_PRE: Stage.EXTRA_FIRST_HALF,
+ Stage.EXTRA_SECOND_HALF_PRE: Stage.EXTRA_SECOND_HALF,
+ }
+ if command in (RefereeCommand.NORMAL_START, RefereeCommand.FORCE_START):
+ active = _PRE_TO_ACTIVE.get(self.stage)
+ if active is not None:
+ self.advance_stage(active, timestamp)
+
+ logger.info("Referee command manually set to: %s", command.name)
+
+ def force_command(
+ self,
+ command: RefereeCommand,
+ timestamp: float,
+ ball_placement_target: Optional[tuple[float, float]] = None,
+ ) -> None:
+ """Directly set the command, bypassing the STOP-first guard.
+
+ For god-mode / test use only — skips the normal safety interlock that
+ inserts STOP before set-piece commands.
+ """
+ self.command = command
+ self.command_counter += 1
+ self.command_timestamp = timestamp
+ self._post_ball_placement_command = None
+ self.status_message = None
+ self._advance2_ready_since = math.inf
+ self._advance3_ready_since = math.inf
+ self._advance4_ready_since = math.inf
+ if ball_placement_target is not None:
+ self.ball_placement_target = ball_placement_target
+ if command in self._BALL_PLACEMENT_COMMANDS:
+ # Auto-advance 4 requires next_command to be set.
+ self.next_command = RefereeCommand.NORMAL_START
+ else:
+ self.next_command = None
+ logger.info("Referee command force-set to: %s", command.name)
+
+ def advance_stage(self, new_stage: Stage, timestamp: float) -> None:
+ """Advance the game stage."""
+ logger.info("Stage %s → %s", self.stage.name, new_stage.name)
+ self.stage = new_stage
+ self.stage_start_time = timestamp
+
+ # ------------------------------------------------------------------
+ # Internal helpers
+ # ------------------------------------------------------------------
+
+ def _can_transition(self, current_time: float) -> bool:
+ return (current_time - self._last_transition_time) >= _TRANSITION_COOLDOWN
+
+ def _apply_violation(self, violation: RuleViolation, current_time: float) -> None:
+ """Update state in response to a detected violation."""
+ if violation.rule_name == "goal":
+ self._handle_goal(violation, current_time)
+ else:
+ self._handle_foul(violation, current_time)
+
+ self._last_transition_time = current_time
+
+ def _handle_goal(self, violation: RuleViolation, current_time: float) -> None:
+ # Determine scorer from next_command (loser gets the kickoff).
+ if violation.next_command == RefereeCommand.PREPARE_KICKOFF_BLUE:
+ # Blue gets kickoff → yellow scored.
+ self.yellow_team.increment_score()
+ logger.info(
+ "Goal by Yellow! Score: Yellow %d – Blue %d",
+ self.yellow_team.score,
+ self.blue_team.score,
+ )
+ elif violation.next_command == RefereeCommand.PREPARE_KICKOFF_YELLOW:
+ # Yellow gets kickoff → blue scored.
+ self.blue_team.increment_score()
+ logger.info(
+ "Goal by Blue! Score: Yellow %d – Blue %d",
+ self.yellow_team.score,
+ self.blue_team.score,
+ )
+
+ self.command = RefereeCommand.STOP
+ self.command_counter += 1
+ self.command_timestamp = current_time
+ self.next_command = self._ball_placement_command_for(violation.next_command) or violation.next_command
+ self._post_ball_placement_command = (
+ violation.next_command if self.next_command in self._BALL_PLACEMENT_COMMANDS else None
+ )
+ self.ball_placement_target = (0.0, 0.0)
+ self.status_message = violation.status_message
+ self._stop_entered_time = current_time
+
+ def _handle_foul(self, violation: RuleViolation, current_time: float) -> None:
+ self.command = violation.suggested_command
+ self.command_counter += 1
+ self.command_timestamp = current_time
+ placement_command = (
+ self._ball_placement_command_for(violation.next_command)
+ if violation.designated_position is not None
+ else None
+ )
+ self.next_command = placement_command or violation.next_command
+ self._post_ball_placement_command = violation.next_command if placement_command is not None else None
+ self.ball_placement_target = violation.designated_position
+ self.status_message = violation.status_message
+ logger.info(
+ "Foul detected: %s → %s (next: %s)",
+ violation.rule_name,
+ violation.suggested_command.name,
+ violation.next_command.name if violation.next_command else "None",
+ )
+
+ def _generate_referee_data(self, current_time: float) -> RefereeData:
+ stage_time_left = max(0.0, self.stage_duration - (current_time - self.stage_start_time))
+ return RefereeData(
+ source_identifier="custom_referee",
+ time_sent=current_time,
+ time_received=current_time,
+ referee_command=self.command,
+ referee_command_timestamp=self.command_timestamp,
+ stage=self.stage,
+ stage_time_left=stage_time_left,
+ blue_team=copy.copy(self.blue_team),
+ yellow_team=copy.copy(self.yellow_team),
+ designated_position=self.ball_placement_target,
+ blue_team_on_positive_half=None,
+ next_command=self.next_command,
+ current_action_time_remaining=None,
+ status_message=self.status_message,
+ )
diff --git a/utama_core/data_processing/refiners/robot_info.py b/utama_core/data_processing/refiners/robot_info.py
index 5b519cdb..315ff25f 100644
--- a/utama_core/data_processing/refiners/robot_info.py
+++ b/utama_core/data_processing/refiners/robot_info.py
@@ -1,26 +1,65 @@
import warnings
from dataclasses import replace
-from typing import List
+from typing import FrozenSet, List, Optional
+from utama_core.config.physical_constants import ROBOT_RADIUS
from utama_core.data_processing.refiners.base_refiner import BaseRefiner
from utama_core.entities.data.command import RobotResponse
from utama_core.entities.game.game_frame import GameFrame
# TODO: current doesn't handle has_ball for enemy robots. In future, implement using vision data
+# Distance threshold for vision-based has_ball inference: robot centre + small buffer.
+_BALL_CAPTURE_DIST = ROBOT_RADIUS + 0.04 # ~0.13 m
+
class RobotInfoRefiner(BaseRefiner):
+ """Merges IR-sensor robot responses into the game frame.
+
+ Args:
+ trusted_ir_robots: Set of robot IDs (vision IDs) whose IR sensor is known to be
+ working. Robots in this set use the raw sensor reading directly. Any robot
+ ID **not** in this set has its has_ball inferred from vision proximity
+ (~0.13 m threshold) instead. Pass ``None`` (default) to trust every
+ robot's IR sensor — this is the normal stable-hardware behaviour and can
+ be restored by simply removing the argument.
+ """
+
+ def __init__(self, trusted_ir_robots: Optional[FrozenSet[int]] = None):
+ self._trusted_ir_robots = trusted_ir_robots
+
def refine(self, game_frame: GameFrame, robot_responses: List[RobotResponse]):
- if robot_responses is None or len(robot_responses) == 0:
+ if self._trusted_ir_robots is None and (robot_responses is None or len(robot_responses) == 0):
return game_frame
friendly_robots = game_frame.friendly_robots.copy()
- for robot_response in robot_responses:
- id = robot_response.id
- if id in friendly_robots:
- robot = friendly_robots[id]
- friendly_robots[id] = replace(robot, has_ball=robot_response.has_ball)
- else:
- warnings.warn(f"Robot ID {id} in robot responses not found in friendly robots. ")
+
+ # When a trust list is active, first apply vision-proximity inference for every
+ # untrusted robot. This ensures that robots which did not return a response this
+ # tick (non-blocking real-mode polling can skip robots) do not keep stale values.
+ if self._trusted_ir_robots is not None:
+ for robot_id, robot in friendly_robots.items():
+ if robot_id not in self._trusted_ir_robots:
+ friendly_robots[robot_id] = replace(robot, has_ball=self._infer_has_ball(game_frame, robot))
+
+ # Overlay IR readings for trusted robots that responded this tick.
+ if robot_responses:
+ for robot_response in robot_responses:
+ rid = robot_response.id
+ if rid not in friendly_robots:
+ warnings.warn(f"Robot ID {rid} in robot responses not found in friendly robots. ")
+ continue
+
+ if self._trusted_ir_robots is None or rid in self._trusted_ir_robots:
+ friendly_robots[rid] = replace(friendly_robots[rid], has_ball=robot_response.has_ball)
+
new_game_frame = replace(game_frame, friendly_robots=friendly_robots)
return new_game_frame
+
+ @staticmethod
+ def _infer_has_ball(game_frame: GameFrame, robot) -> bool:
+ """Proximity-based has_ball: True when robot is within capture distance of ball."""
+ if game_frame.ball is None:
+ return False
+ ball_2d = game_frame.ball.p.to_2d()
+ return robot.p.distance_to(ball_2d) < _BALL_CAPTURE_DIST
diff --git a/utama_core/global_utils/mapping_utils.py b/utama_core/global_utils/mapping_utils.py
index 5d5971a0..3ce236f4 100644
--- a/utama_core/global_utils/mapping_utils.py
+++ b/utama_core/global_utils/mapping_utils.py
@@ -23,6 +23,23 @@ def map_friendly_enemy_to_colors(my_team_is_yellow: bool, friendly_item: T, enem
return yellow_item, blue_item
+# NB: actually map_friendly_enemy_to_colors and map_colors_to_friendly_enemy have the same logic
+
+
+def map_colors_to_friendly_enemy(my_team_is_yellow: bool, yellow_item: T, blue_item: T) -> Tuple[T, T]:
+ """Map yellow and blue items to their respective friendly and enemy items based on my team color.
+
+ Args:
+ my_team_is_yellow (bool): True if the team is yellow, False if blue.
+ yellow_item (T): Any item from the yellow team (int, list, etc.)
+ blue_item (T): Any item from the blue team (int, list, etc.)
+
+ Returns:
+ Tuple[T, T]: A tuple of (friendly_item, enemy_item).
+ """
+ return map_friendly_enemy_to_colors(my_team_is_yellow, yellow_item, blue_item)
+
+
def map_left_right_to_colors(my_team_is_yellow: bool, my_team_is_right: bool, right_item: T, left_item: T):
"""Map left and right items to their respective colors based on my team color and position.
diff --git a/utama_core/run/strategy_runner.py b/utama_core/run/strategy_runner.py
index c47cc35d..f748517b 100644
--- a/utama_core/run/strategy_runner.py
+++ b/utama_core/run/strategy_runner.py
@@ -6,7 +6,7 @@
import warnings
from collections import deque
from dataclasses import dataclass, field
-from typing import TYPE_CHECKING, List, Optional, Tuple
+from typing import TYPE_CHECKING, FrozenSet, List, Optional, Tuple
from rich.live import Live
from rich.text import Text
@@ -14,7 +14,7 @@
from utama_core.config.enums import Mode, mode_str_to_enum
from utama_core.config.field_params import STANDARD_FIELD_DIMS, FieldDimensions
from utama_core.config.formations import FormationType, get_formations
-from utama_core.config.physical_constants import MAX_ROBOTS
+from utama_core.config.physical_constants import MAX_ROBOT_ID, MAX_ROBOTS
from utama_core.config.settings import (
FPS_PRINT_INTERVAL,
MAX_CAMERAS,
@@ -29,12 +29,13 @@
RobotInfoRefiner,
VelocityRefiner,
)
-from utama_core.entities.data.command import RobotCommand
+from utama_core.entities.data.command import RobotCommand, RobotResponse
from utama_core.entities.data.raw_vision import RawVisionData
from utama_core.entities.game import Game, GameFrame, GameHistory
from utama_core.entities.game.field import Field, FieldBounds
from utama_core.entities.referee.referee_command import RefereeCommand
from utama_core.global_utils.mapping_utils import (
+ map_colors_to_friendly_enemy,
map_friendly_enemy_to_colors,
map_left_right_to_colors,
)
@@ -135,6 +136,14 @@ class StrategyRunner:
instance to use the in-process referee, ``OfficialReferee()`` to consume
commands from the SSL game-controller over the network, or ``None``
(default) to run without any referee input.
+ yellow_vision_to_cmd_mapping (dict[int, int], optional): Mapping from vision robot IDs to command robot IDs for the yellow team.
+ Used only in real mode. In real PVP/shared-transmitter mode, mappings are required for both teams and must include all expected robots.
+ blue_vision_to_cmd_mapping (dict[int, int], optional): Mapping from vision robot IDs to command robot IDs for the blue team.
+ Used only in real mode. In real PVP/shared-transmitter mode, mappings are required for both teams and must include all expected robots.
+ yellow_trusted_ir_robots (FrozenSet[int], optional): Vision IDs of yellow-team robots whose IR (has_ball) sensor
+ is confirmed working. Robots NOT in this set fall back to vision-proximity inference (~0.13 m).
+ Pass ``None`` (default) to trust all IR sensors — remove this argument once sensors are stable.
+ blue_trusted_ir_robots (FrozenSet[int], optional): Same as ``yellow_trusted_ir_robots`` for the blue team.
"""
def __init__(
@@ -160,6 +169,10 @@ def __init__(
filtering: bool = False,
referee: RefereeSource = None,
formation_type: Optional[FormationType] = None,
+ yellow_vision_to_cmd_mapping: Optional[dict[int, int]] = None,
+ blue_vision_to_cmd_mapping: Optional[dict[int, int]] = None,
+ yellow_trusted_ir_robots: Optional[FrozenSet[int]] = None,
+ blue_trusted_ir_robots: Optional[FrozenSet[int]] = None,
):
self.logger = logging.getLogger(__name__)
@@ -193,8 +206,16 @@ def __init__(
)
self.referee_refiner = RefereeRefiner()
+ my_trusted_ir = yellow_trusted_ir_robots if my_team_is_yellow else blue_trusted_ir_robots
+ opp_trusted_ir = blue_trusted_ir_robots if my_team_is_yellow else yellow_trusted_ir_robots
self.my, self.opp = self._setup_sides_data(
- strategy, opp_strategy, filtering, control_scheme, opp_control_scheme
+ strategy,
+ opp_strategy,
+ filtering,
+ control_scheme,
+ opp_control_scheme,
+ my_trusted_ir_robots=my_trusted_ir,
+ opp_trusted_ir_robots=opp_trusted_ir,
)
### functions below rely on self.my and self.opp ###
@@ -202,6 +223,21 @@ def __init__(
self.rsim_env, self.sim_controller = self._load_sim(rsim_noise, rsim_vanishing)
self._assert_exp_robots_and_ball(exp_friendly, exp_enemy, exp_ball)
+ # mapping for mismatch between vision and cmd ids
+ self.yellow_vision_to_cmd_mapping = self._validate_vision_to_cmd_mapping(
+ yellow_vision_to_cmd_mapping, is_yellow=True
+ )
+ self.blue_vision_to_cmd_mapping = self._validate_vision_to_cmd_mapping(
+ blue_vision_to_cmd_mapping, is_yellow=False
+ )
+ self.yellow_cmd_to_vision_mapping = {v: k for k, v in self.yellow_vision_to_cmd_mapping.items()}
+ self.blue_cmd_to_vision_mapping = {v: k for k, v in self.blue_vision_to_cmd_mapping.items()}
+
+ if self.opp and self.mode == Mode.REAL:
+ self._check_no_cmd_duplicate_if_transmission_sharing(
+ self.yellow_vision_to_cmd_mapping, self.blue_vision_to_cmd_mapping
+ )
+
self._load_robot_controllers()
# Remove Rsim ball. Rsim does not have the flexibilty to start without a ball.
@@ -259,6 +295,77 @@ def __init__(
self.profiler_name = profiler_name
self.profiler = cProfile.Profile() if profiler_name else None
+ def _validate_vision_to_cmd_mapping(self, mapping: Optional[dict[int, int]], is_yellow: bool) -> dict[int, int]:
+ if self.mode == Mode.REAL:
+ explicitly_provided = mapping is not None
+ if mapping is None:
+ if self.opp:
+ raise ValueError(
+ "explicit vision_to_cmd_mapping is required for both teams in real PVP/shared-transmitter mode."
+ )
+ return {}
+
+ if not isinstance(mapping, dict):
+ raise TypeError(
+ f"vision_to_cmd_mapping must be a dictionary mapping vision robot IDs to command robot IDs; got {type(mapping).__name__}."
+ )
+
+ # if we are not running an opp strat, but mapping provided, warn that it will be ignored
+ if self.opp is None and self.my_team_is_yellow ^ is_yellow:
+ warnings.warn(
+ "vision_to_cmd_mapping is provided but will be ignored since the opponent team is not being controlled."
+ )
+
+ if self.opp and explicitly_provided:
+ if is_yellow ^ self.my_team_is_yellow:
+ if len(mapping) != self.exp_enemy:
+ raise ValueError(
+ "vision_to_cmd_mapping for opponent team must include all expected opponent robots for shared transmission."
+ )
+ else:
+ if len(mapping) != self.exp_friendly:
+ raise ValueError(
+ "vision_to_cmd_mapping for friendly team must include all expected friendly robots for shared transmission."
+ )
+
+ for vision_id, cmd_id in mapping.items():
+ if not isinstance(vision_id, int) or not isinstance(cmd_id, int):
+ raise TypeError(
+ f"vision_to_cmd_mapping must map integers to integers; got key type {type(vision_id).__name__} and value type {type(cmd_id).__name__}."
+ )
+ if vision_id < 0 or cmd_id < 0:
+ raise ValueError(
+ f"vision_to_cmd_mapping cannot have negative IDs; got vision ID {vision_id} and command ID {cmd_id}."
+ )
+ if vision_id > MAX_ROBOT_ID:
+ raise ValueError(
+ f"vision_to_cmd_mapping cannot have vision IDs greater than {MAX_ROBOT_ID}; got vision ID {vision_id}."
+ )
+ if cmd_id > 0xFF:
+ raise ValueError(
+ f"vision_to_cmd_mapping cannot have command IDs greater than 255 (1 byte limit); got command ID {cmd_id}."
+ )
+ return mapping
+ else:
+ if mapping is not None:
+ raise ValueError(
+ "vision_to_cmd_mapping should not be provided in simulation modes; robot ID mapping is only needed in real mode."
+ )
+ return {}
+
+ def _check_no_cmd_duplicate_if_transmission_sharing(
+ self, yellow_mapping: dict[int, int], blue_mapping: dict[int, int]
+ ):
+ seen = set()
+ dicts = [yellow_mapping, blue_mapping]
+ for d in dicts:
+ for v in d.values():
+ if v in seen:
+ raise ValueError(
+ f"vision_to_cmd_mapping for friendly and opponent teams cannot have overlapping command IDs since commands are transmitted together; duplicate command ID: {v}."
+ )
+ seen.add(v)
+
def _handle_sigint(self, sig, frame):
self._stop_event.set()
signal.default_int_handler(sig, frame)
@@ -343,6 +450,8 @@ def _setup_sides_data(
filtering: bool,
control_scheme: str,
opp_control_scheme: Optional[str],
+ my_trusted_ir_robots: Optional[FrozenSet[int]] = None,
+ opp_trusted_ir_robots: Optional[FrozenSet[int]] = None,
) -> Tuple[SideRuntime, Optional[SideRuntime]]:
"""Setup the data structures for both sides (my team and opponent)
Args:
@@ -351,6 +460,8 @@ def _setup_sides_data(
filtering (bool): Whether to use filtering in the position refiners.
control_scheme (str): Name of the motion control scheme to use for the friendly team.
opp_control_scheme (Optional[str]): Name of the motion control scheme to use for the opponent team. If not set, uses same as friendly.
+ my_trusted_ir_robots (FrozenSet[int], optional): Vision IDs of friendly robots whose IR sensor is trusted.
+ opp_trusted_ir_robots (FrozenSet[int], optional): Vision IDs of opponent robots whose IR sensor is trusted.
Side effect: Initializes the SideRuntime for both friendly and opponent sides, including their strategies, refiners, and motion controllers.
@@ -359,7 +470,10 @@ def _setup_sides_data(
"""
opp_side = None
my_pos_ref, my_vel_ref, my_robot_ref = self._init_refiners(
- self.full_field_dims, filtering=filtering, exp_ball=self.exp_ball
+ self.full_field_dims,
+ filtering=filtering,
+ exp_ball=self.exp_ball,
+ trusted_ir_robots=my_trusted_ir_robots,
)
my_motion_controller = get_control_scheme(control_scheme)
my_strategy.setup_strategy_blackboard(is_opp_strat=False)
@@ -373,7 +487,10 @@ def _setup_sides_data(
if opp_strategy is not None:
opp_pos_ref, opp_vel_ref, opp_robot_ref = self._init_refiners(
- self.full_field_dims, filtering=filtering, exp_ball=self.exp_ball
+ self.full_field_dims,
+ filtering=filtering,
+ exp_ball=self.exp_ball,
+ trusted_ir_robots=opp_trusted_ir_robots,
)
opp_motion_controller = (
get_control_scheme(opp_control_scheme) if opp_control_scheme is not None else my_motion_controller
@@ -389,6 +506,41 @@ def _setup_sides_data(
return my_side, opp_side
+ def _split_robot_responses_by_team(
+ self, responses: List[RobotResponse]
+ ) -> Tuple[List[RobotResponse], List[RobotResponse]]:
+ """Split a list of RobotResponse objects into separate lists for the friendly and opponent teams
+ based on the robot IDs and the vision.
+ """
+ friendly_responses = []
+ opponent_responses = []
+
+ for response in responses:
+ cmd_id = response.id
+
+ if self.my_team_is_yellow:
+ vision_id = self.yellow_cmd_to_vision_mapping.get(cmd_id)
+ if vision_id is not None:
+ friendly_responses.append(RobotResponse(vision_id, response.has_ball))
+ else:
+ opp_vision_id = self.blue_cmd_to_vision_mapping.get(cmd_id)
+ if opp_vision_id is not None:
+ opponent_responses.append(RobotResponse(opp_vision_id, response.has_ball))
+ else:
+ self.logger.warning(f"RobotResponse cmd_id={cmd_id} not found in either yellow or blue mapping")
+ else:
+ vision_id = self.blue_cmd_to_vision_mapping.get(cmd_id)
+ if vision_id is not None:
+ friendly_responses.append(RobotResponse(vision_id, response.has_ball))
+ else:
+ opp_vision_id = self.yellow_cmd_to_vision_mapping.get(cmd_id)
+ if opp_vision_id is not None:
+ opponent_responses.append(RobotResponse(opp_vision_id, response.has_ball))
+ else:
+ self.logger.warning(f"RobotResponse cmd_id={cmd_id} not found in either blue or yellow mapping")
+
+ return friendly_responses, opponent_responses
+
def _remove_rsim_ball(self):
"""Removes the ball from the RSim environment by teleporting it off-field."""
self.sim_controller.remove_ball()
@@ -632,12 +784,23 @@ def _load_robot_controllers(self):
)
elif self.mode == Mode.REAL:
+ my_viz_to_cmd_mapping, opp_viz_to_cmd_mapping = map_colors_to_friendly_enemy(
+ self.my_team_is_yellow,
+ self.yellow_vision_to_cmd_mapping,
+ self.blue_vision_to_cmd_mapping,
+ )
my_robot_controller = RealRobotController(
- is_team_yellow=self.my_team_is_yellow, n_friendly=self.exp_friendly
+ is_team_yellow=self.my_team_is_yellow,
+ n_friendly=self.exp_friendly,
+ vision_to_cmd_mapping=my_viz_to_cmd_mapping,
)
if self.opp:
+ serial = my_robot_controller.serial_port # share serial connection for efficiency
opp_robot_controller = RealRobotController(
- is_team_yellow=not self.my_team_is_yellow, n_friendly=self.exp_enemy
+ is_team_yellow=not self.my_team_is_yellow,
+ n_friendly=self.exp_enemy,
+ vision_to_cmd_mapping=opp_viz_to_cmd_mapping,
+ serial_port=serial,
)
else:
@@ -654,14 +817,17 @@ def _init_refiners(
field_dims: FieldDimensions,
filtering: bool,
exp_ball: bool = True,
+ trusted_ir_robots: Optional[FrozenSet[int]] = None,
) -> tuple[PositionRefiner, VelocityRefiner, RobotInfoRefiner]:
"""
Initialize the position, velocity, and robot info refiners.
Args:
- field_bounds (FieldBounds): The bounds of the field.
+ field_dims (FieldDimensions): The field dimensions.
filtering (bool): Whether to use filtering in the position refiner.
exp_ball (bool): Whether the ball is expected. When False, the position refiner is
allowed to return None if no ball is detected in raw vision data.
+ trusted_ir_robots (FrozenSet[int], optional): Vision IDs of robots whose IR sensor is trusted.
+ See RobotInfoRefiner for details.
Returns:
tuple: The initialized PositionRefiner, VelocityRefiner, and RobotInfoRefiner.
"""
@@ -671,7 +837,7 @@ def _init_refiners(
exp_ball=exp_ball,
)
velocity_refiner = VelocityRefiner()
- robot_info_refiner = RobotInfoRefiner()
+ robot_info_refiner = RobotInfoRefiner(trusted_ir_robots=trusted_ir_robots)
return position_refiner, velocity_refiner, robot_info_refiner
@@ -928,15 +1094,36 @@ def _run_step(self):
self._last_referee_data = self.ref_buffer.popleft()
referee_data = self._last_referee_data
+ friendly_res, opp_res = None, None
+ if self.mode == Mode.REAL:
+ responses = self.my.strategy.robot_controller.get_robots_responses()
+ if self.opp:
+ friendly_res, opp_res = self._split_robot_responses_by_team(responses)
+ else:
+ cmd_to_vision = (
+ self.yellow_cmd_to_vision_mapping if self.my_team_is_yellow else self.blue_cmd_to_vision_mapping
+ )
+ if cmd_to_vision:
+ friendly_res = []
+ for r in responses:
+ vision_id = cmd_to_vision.get(r.id)
+ if vision_id is None:
+ self.logger.warning(f"RobotResponse cmd_id={r.id} not found in mapping for controlled team")
+ continue
+ friendly_res.append(RobotResponse(vision_id, r.has_ball))
+ else:
+ friendly_res = responses
+
# alternate between opp and friendly playing
+ real = self.mode == Mode.REAL
if self.toggle_opp_first:
if self.opp:
- self._step_game(vision_frames, referee_data, True)
- self._step_game(vision_frames, referee_data, False)
+ self._step_game(vision_frames, referee_data, True, real_responses=opp_res if real else None)
+ self._step_game(vision_frames, referee_data, False, real_responses=friendly_res if real else None)
else:
- self._step_game(vision_frames, referee_data, False)
+ self._step_game(vision_frames, referee_data, False, real_responses=friendly_res if real else None)
if self.opp:
- self._step_game(vision_frames, referee_data, True)
+ self._step_game(vision_frames, referee_data, True, real_responses=opp_res if real else None)
self.toggle_opp_first = not self.toggle_opp_first
# --- rate limiting ---
@@ -1020,6 +1207,7 @@ def _step_game(
vision_frames: List[RawVisionData],
referee_data,
running_opp: bool,
+ real_responses: Optional[List[RobotResponse]] = None,
):
"""Step the game for the robot controller and strategy.
@@ -1027,11 +1215,16 @@ def _step_game(
vision_frames (List[RawVisionData]): The vision frames.
referee_data: The referee data from RSim or network receiver.
running_opp (bool): Whether to run the opponent strategy.
+ real_responses (Optional[List[RobotResponse]]): The robot responses pulled for real.
+ We use a shared transmitter, so it cannot be pulled per side.
"""
side = self.opp if running_opp else self.my
# Pull responses from robot controller
- responses = side.strategy.robot_controller.get_robots_responses()
+ if self.mode != Mode.REAL:
+ responses = side.strategy.robot_controller.get_robots_responses()
+ else:
+ responses = real_responses if real_responses is not None else []
# Update game frame with refined information
new_game_frame = side.position_refiner.refine(side.current_game_frame, vision_frames)
diff --git a/utama_core/strategy/common/abstract_strategy.py b/utama_core/strategy/common/abstract_strategy.py
index 091acdfc..cddfa89b 100644
--- a/utama_core/strategy/common/abstract_strategy.py
+++ b/utama_core/strategy/common/abstract_strategy.py
@@ -203,6 +203,7 @@ def setup_strategy_blackboard(self, is_opp_strat: bool):
Setups the blackboard based on if is_opp_strat.
"""
+ self._is_opp_strat = is_opp_strat
self.blackboard = self._setup_blackboard(is_opp_strat)
def setup_behaviour_tree(self, is_opp_strat: bool):
diff --git a/utama_core/strategy/examples/__init__.py b/utama_core/strategy/examples/__init__.py
index a59b9b55..d85dcc67 100644
--- a/utama_core/strategy/examples/__init__.py
+++ b/utama_core/strategy/examples/__init__.py
@@ -1,4 +1,11 @@
+from utama_core.strategy.examples.ball_placement_and_kick_strategy import (
+ BallPlacementAndKickStrategy,
+ KickAfterDirectFreeStep,
+)
from utama_core.strategy.examples.defense_strategy import DefenceStrategy
+from utama_core.strategy.examples.deliberate_out_of_bounds_strategy import (
+ DeliberateOutOfBoundsStrategy,
+)
from utama_core.strategy.examples.go_to_ball_ex import GoToBallExampleStrategy
from utama_core.strategy.examples.motion_planning.multi_robot_navigation_strategy import (
MultiRobotNavigationStrategy,
diff --git a/utama_core/strategy/examples/ball_placement_and_kick_strategy.py b/utama_core/strategy/examples/ball_placement_and_kick_strategy.py
new file mode 100644
index 00000000..d26e3270
--- /dev/null
+++ b/utama_core/strategy/examples/ball_placement_and_kick_strategy.py
@@ -0,0 +1,116 @@
+from typing import Optional
+
+import py_trees
+
+from utama_core.entities.data.command import RobotCommand
+from utama_core.entities.referee.referee_command import RefereeCommand
+from utama_core.strategy.common import AbstractBehaviour
+from utama_core.strategy.examples.ball_placement_strategy import BallPlacementStrategy
+
+_DIRECT_FREE_COMMANDS = frozenset(
+ {
+ RefereeCommand.DIRECT_FREE_YELLOW,
+ RefereeCommand.DIRECT_FREE_BLUE,
+ }
+)
+
+
+class _CommandTracker:
+ """Mutable container updated by BallPlacementAndKickStrategy.step() every tick.
+
+ Because KickAfterDirectFreeStep is not ticked while the referee-override tree
+ handles DIRECT_FREE_* commands, it cannot observe that command transition
+ directly. The strategy's step() method samples the referee command before
+ each tree tick and records it here so the behaviour can read it on the next
+ NORMAL_START tick.
+ """
+
+ def __init__(self):
+ self.prev_command: Optional[RefereeCommand] = None
+
+
+class KickAfterDirectFreeStep(AbstractBehaviour):
+ """Kick once on each NORMAL_START that follows a direct-free command."""
+
+ _KICK_DISTANCE = 0.22
+
+ def __init__(self, name: str, tracker: _CommandTracker):
+ super().__init__(name=name)
+ self._tracker = tracker
+
+ def setup_(self):
+ self._active_restart_timestamp: float | None = None
+ self._kick_sent_for_timestamp: float | None = None
+
+ def update(self) -> py_trees.common.Status:
+ game = self.blackboard.game
+ ref = game.referee
+
+ if ref is None:
+ return py_trees.common.Status.RUNNING
+
+ current_command = ref.referee_command
+
+ if current_command != RefereeCommand.NORMAL_START:
+ return py_trees.common.Status.RUNNING
+
+ # Only kick if we transitioned here from a direct free (not from a kickoff
+ # seed / force-start at game start). _tracker.prev_command is maintained
+ # by the strategy's step() so it reflects commands seen during ticks when
+ # this node was overridden by the RefereeOverride subtree.
+ if self._tracker.prev_command not in _DIRECT_FREE_COMMANDS:
+ return py_trees.common.Status.RUNNING
+
+ restart_timestamp = ref.referee_command_timestamp
+ if restart_timestamp != self._active_restart_timestamp:
+ self._active_restart_timestamp = restart_timestamp
+ self._kick_sent_for_timestamp = None
+
+ if self._kick_sent_for_timestamp == restart_timestamp:
+ return py_trees.common.Status.RUNNING
+
+ if game.ball is None or not game.friendly_robots:
+ return py_trees.common.Status.RUNNING
+
+ kicker_id = min(
+ game.friendly_robots,
+ key=lambda rid: game.friendly_robots[rid].p.distance_to(game.ball.p),
+ )
+ kicker = game.friendly_robots[kicker_id]
+ if kicker.p.distance_to(game.ball.p) > self._KICK_DISTANCE:
+ return py_trees.common.Status.RUNNING
+
+ self.blackboard.cmd_map[kicker_id] = RobotCommand(
+ local_forward_vel=0,
+ local_left_vel=0,
+ angular_vel=0,
+ kick=1,
+ chip=0,
+ dribble=0,
+ )
+ self._kick_sent_for_timestamp = restart_timestamp
+
+ return py_trees.common.Status.RUNNING
+
+
+class BallPlacementAndKickStrategy(BallPlacementStrategy):
+ """Use referee ball placement/direct-free setup, then kick once on NORMAL_START."""
+
+ def __init__(self):
+ self._tracker = _CommandTracker()
+ super().__init__()
+
+ def create_behaviour_tree(self) -> py_trees.behaviour.Behaviour:
+ root = py_trees.composites.Sequence(name="PlacementRestartKickRoot", memory=False)
+ root.add_child(KickAfterDirectFreeStep(name="KickAfterDirectFree", tracker=self._tracker))
+ return root
+
+ def step(self):
+ # Sample the referee command *before* the tree tick so that transitions
+ # through DIRECT_FREE_* (which are handled by the RefereeOverride subtree
+ # and never reach KickAfterDirectFreeStep) are still recorded in the tracker.
+ game = self.blackboard.game
+ ref = game.referee
+ if ref is not None:
+ self._tracker.prev_command = ref.referee_command
+ super().step()
diff --git a/utama_core/strategy/examples/ball_placement_strategy.py b/utama_core/strategy/examples/ball_placement_strategy.py
new file mode 100644
index 00000000..f22611f5
--- /dev/null
+++ b/utama_core/strategy/examples/ball_placement_strategy.py
@@ -0,0 +1,182 @@
+"""ball_placement_strategy.py — Skeleton for the ball placement feature.
+
+Your task
+---------
+Implement ``BallPlacementStep.update()`` so that, when the referee issues
+BALL_PLACEMENT_YELLOW (our team places), one robot picks up the ball with its
+dribbler and carries it to ``game.referee.designated_position``, while the
+other robot waits clear of the ball.
+
+The referee integration (deciding *when* to use this behaviour) is already
+wired up in the referee override tree — you do not need to touch the referee
+config. Your work is entirely inside this file's strategy and behaviour tree.
+
+How ball placement works
+------------------------
+1. Referee issues BALL_PLACEMENT_YELLOW with a ``designated_position`` (x, y).
+2. One robot (the "placer") drives to the ball with its dribbler on to capture it.
+3. Once it has the ball (``robot.has_ball`` is True), it carries it to
+ ``designated_position`` and stops.
+4. When the ball is within ``BALL_PLACEMENT_DONE_DISTANCE`` of the target the
+ referee auto-advances to DIRECT_FREE_YELLOW → NORMAL_START.
+5. All other robots must stay at least ``BALL_KEEP_OUT_DISTANCE`` from the ball.
+
+Useful references
+-----------------
+- ``game.referee.designated_position`` — (x, y) tuple, the placement target.
+- ``game.ball.p`` — current ball position (Vector2D-like).
+- ``robot.has_ball`` — True when the robot's IR dribbler sensor
+ detects the ball.
+- ``move(game, motion_controller, robot_id, target, orientation, dribbling=True)``
+ — send a motion command; pass ``dribbling=True`` to activate the dribbler.
+- ``empty_command(False)`` — stop a robot in place.
+- ``BALL_PLACEMENT_DONE_DISTANCE`` — 0.15 m; ball must be this close to target.
+- ``BALL_KEEP_OUT_DISTANCE`` — 0.8 m; clearance for non-placer robots.
+
+Running the demo
+----------------
+ pixi run python demo_ball_placement.py
+
+The RSim window opens and the browser GUI at http://localhost:8080 lets you
+issue BALL_PLACEMENT_YELLOW commands from the "Manual Commands" panel and watch
+your strategy respond.
+
+Running the tests
+-----------------
+ pixi run pytest utama_core/tests/strategy_runner/test_ball_placement_rsim.py -v
+
+There are three tests that must pass before the feature is considered complete:
+ 1. placer robot drives toward the ball after the command is issued.
+ 2. placer carries the ball toward the designated position (dribbler on).
+ 3. non-placer robot stays outside the keep-out radius throughout.
+"""
+
+import math
+from typing import Optional
+
+import py_trees
+
+from utama_core.config.referee_constants import (
+ BALL_KEEP_OUT_DISTANCE,
+ BALL_PLACEMENT_DONE_DISTANCE,
+)
+from utama_core.entities.data.vector import Vector2D
+from utama_core.skills.src.utils.move_utils import empty_command, move, turn_on_spot
+from utama_core.strategy.common import AbstractBehaviour, AbstractStrategy
+from utama_core.strategy.referee.actions import _clear_to_legal_positions
+
+
+class BallPlacementStep(AbstractBehaviour):
+ """Move one robot to place the ball at the referee's designated position.
+
+ Implementation guide
+ --------------------
+ The ``update()`` method is called every simulation tick while the referee
+ command is BALL_PLACEMENT_YELLOW. It must:
+
+ 1. Read ``game.referee.designated_position`` — the (x, y) target tuple.
+ 2. If the ball is already at the target (within ``BALL_PLACEMENT_DONE_DISTANCE``),
+ stop all robots and return RUNNING (the referee will advance the state).
+ 3. Pick the robot closest to the ball as the placer.
+ 4. If the placer does not yet have the ball (``robot.has_ball`` is False),
+ drive it to the ball with the dribbler on.
+ 5. Once the placer has the ball, drive it to ``designated_position`` with
+ the dribbler still on.
+ 6. Keep all non-placer robots at least ``BALL_KEEP_OUT_DISTANCE`` away from
+ the ball by calling ``_clear_to_legal_positions``.
+
+ Return ``py_trees.common.Status.RUNNING`` on every tick (the referee
+ override tree holds here until the command changes).
+
+ Tips
+ ----
+ - Use ``Vector2D(target[0], target[1])`` to convert the tuple to a Vector2D.
+ - Orientation: ``robot.p.angle_to(target_pos)`` gives the heading angle.
+ - Call ``move(..., dribbling=True)`` to activate the dribbler.
+ - Write commands to ``self.blackboard.cmd_map[robot_id]`` for each robot.
+ """
+
+ def update(self) -> py_trees.common.Status:
+ game = self.blackboard.game
+ ref = game.referee
+ motion_controller = self.blackboard.motion_controller
+
+ if ref is None or game.ball is None or not game.friendly_robots:
+ return self._stop_all()
+
+ target = ref.designated_position
+ if target is None:
+ return self._stop_all()
+
+ target_pos = Vector2D(target[0], target[1])
+ ball = game.ball
+
+ if ball.p.distance_to(target_pos) <= BALL_PLACEMENT_DONE_DISTANCE:
+ return self._stop_all()
+
+ placer_id = min(
+ game.friendly_robots,
+ key=lambda rid: game.friendly_robots[rid].p.distance_to(ball.p),
+ )
+ placer = game.friendly_robots[placer_id]
+
+ _FACE_READY_ANGLE = 0.2
+
+ if placer.has_ball:
+ orientation = placer.p.angle_to(target_pos)
+ face_error = math.atan2(
+ math.sin(orientation - placer.orientation), math.cos(orientation - placer.orientation)
+ )
+ if abs(face_error) > _FACE_READY_ANGLE:
+ self.blackboard.cmd_map[placer_id] = turn_on_spot(
+ game, motion_controller, placer_id, orientation, dribbling=True
+ )
+ else:
+ self.blackboard.cmd_map[placer_id] = move(
+ game, motion_controller, placer_id, target_pos, orientation, dribbling=True
+ )
+ else:
+ target_for_move = Vector2D(ball.p.x, ball.p.y)
+ orientation = placer.p.angle_to(target_for_move)
+ self.blackboard.cmd_map[placer_id] = move(
+ game, motion_controller, placer_id, target_for_move, orientation, dribbling=True
+ )
+
+ _clear_to_legal_positions(
+ self.blackboard,
+ ball_keep_dist=BALL_KEEP_OUT_DISTANCE,
+ exempt_robot_ids={placer_id},
+ )
+ return py_trees.common.Status.RUNNING
+
+ def _stop_all(self) -> py_trees.common.Status:
+ game = self.blackboard.game
+ for robot_id in game.friendly_robots:
+ self.blackboard.cmd_map[robot_id] = empty_command(False)
+ return py_trees.common.Status.RUNNING
+
+
+class BallPlacementStrategy(AbstractStrategy):
+ """2v2 ball placement strategy for the Exhibition Road field.
+
+ The referee override tree (built into AbstractStrategy) automatically handles
+ HALT, STOP, kickoff, and penalty commands — you only need to implement what
+ happens during BALL_PLACEMENT_YELLOW (i.e., in ``BallPlacementStep``).
+
+ During NORMAL_START / FORCE_START the ``IdleRoot`` sequence at the bottom of
+ the tree runs — replace it with your regular game strategy once placement works.
+ """
+
+ def create_behaviour_tree(self) -> py_trees.behaviour.Behaviour:
+ root = py_trees.composites.Sequence(name="BallPlacementRoot", memory=False)
+ root.add_child(BallPlacementStep(name="PlaceBall"))
+ return root
+
+ def assert_exp_robots(self, n_runtime_friendly: int, n_runtime_enemy: int) -> bool:
+ return n_runtime_friendly >= 1
+
+ def assert_exp_goals(self, includes_my_goal_line: bool, includes_opp_goal_line: bool) -> bool:
+ return True
+
+ def get_min_bounding_req(self) -> Optional[object]:
+ return None
diff --git a/utama_core/strategy/examples/deliberate_out_of_bounds_strategy.py b/utama_core/strategy/examples/deliberate_out_of_bounds_strategy.py
new file mode 100644
index 00000000..db696d52
--- /dev/null
+++ b/utama_core/strategy/examples/deliberate_out_of_bounds_strategy.py
@@ -0,0 +1,215 @@
+"""Opponent strategy that deliberately creates out-of-bounds ball-placement events."""
+
+import math
+
+import py_trees
+
+from utama_core.config.field_params import FieldDimensions
+from utama_core.config.physical_constants import ROBOT_RADIUS
+from utama_core.entities.data.command import RobotCommand
+from utama_core.entities.data.vector import Vector2D
+from utama_core.skills.src.utils.move_utils import empty_command, move
+from utama_core.strategy.common import AbstractBehaviour, AbstractStrategy
+
+
+def _angle_delta(target: float, current: float) -> float:
+ return math.atan2(math.sin(target - current), math.cos(target - current))
+
+
+class DeliberateOutOfBoundsStep(AbstractBehaviour):
+ """Line up behind the ball, shove once toward a boundary, then stop."""
+
+ _TARGETS = ("top", "bottom", "left", "right", "yellow_goal")
+ _BOUNDARY_EPSILON = 0.04
+ _GOAL_EPSILON = 0.06
+ _GOAL_MOUTH_CLEARANCE = 0.28
+ _APPROACH_BACKOFF = ROBOT_RADIUS + 0.10
+ _APPROACH_TOLERANCE = 0.08
+ _ALIGN_TOLERANCE_RAD = 0.14
+ _PUSH_SPEED = 1.9
+ _PUSH_SECONDS = 0.34
+ _STOP_SECONDS = 0.35
+
+ def __init__(self, field_dims: FieldDimensions, name: str = "DeliberateOutOfBoundsStep"):
+ super().__init__(name=name)
+ self._field_dims = field_dims
+ self._target_index = 0
+ self._phase = "approach"
+ self._phase_started_at = 0.0
+ self._waiting_for_restart = False
+
+ def update(self) -> py_trees.common.Status:
+ game = self.blackboard.game
+ motion_controller = self.blackboard.motion_controller
+
+ if game.ball is None or not game.friendly_robots:
+ return self._stop_all()
+
+ ball_pos = Vector2D(game.ball.p.x, game.ball.p.y)
+ half_length = self._field_dims.full_field_half_length
+ half_width = self._field_dims.full_field_half_width
+
+ if self._waiting_for_restart:
+ if abs(ball_pos.x) < half_length - 0.25 and abs(ball_pos.y) < half_width - 0.25:
+ self._waiting_for_restart = False
+ self._phase = "approach"
+ else:
+ return self._stop_all()
+
+ target_name = self._TARGETS[self._target_index]
+ if self._has_reached_target(ball_pos, target_name, game, half_length, half_width):
+ if self._target_index < len(self._TARGETS) - 1:
+ self._target_index += 1
+ self._waiting_for_restart = True
+ self._phase = "approach"
+ return self._stop_all()
+
+ target_point = self._target_point(target_name, ball_pos, game, half_length, half_width)
+ direction = target_point - ball_pos
+ if direction.mag() == 0.0:
+ return self._stop_all()
+ direction = Vector2D(direction.x / direction.mag(), direction.y / direction.mag())
+
+ pusher_id = min(
+ game.friendly_robots,
+ key=lambda rid: game.friendly_robots[rid].p.distance_to(ball_pos),
+ )
+ pusher = game.friendly_robots[pusher_id]
+ setup_point = Vector2D(
+ ball_pos.x - direction.x * self._APPROACH_BACKOFF,
+ ball_pos.y - direction.y * self._APPROACH_BACKOFF,
+ )
+ push_oren = math.atan2(direction.y, direction.x)
+
+ for robot_id in game.friendly_robots:
+ if robot_id != pusher_id:
+ self.blackboard.cmd_map[robot_id] = empty_command(False)
+ continue
+
+ if self._phase == "approach":
+ if pusher.p.distance_to(setup_point) <= self._APPROACH_TOLERANCE:
+ self._phase = "align"
+ self._phase_started_at = game.ts
+ self.blackboard.cmd_map[robot_id] = move(
+ game,
+ motion_controller,
+ robot_id,
+ setup_point,
+ push_oren,
+ dribbling=False,
+ )
+ continue
+
+ if self._phase == "align":
+ aligned = abs(_angle_delta(push_oren, pusher.orientation)) <= self._ALIGN_TOLERANCE_RAD
+ near_setup = pusher.p.distance_to(setup_point) <= self._APPROACH_TOLERANCE * 1.8
+ if aligned and near_setup:
+ self._phase = "push"
+ self._phase_started_at = game.ts
+ else:
+ self.blackboard.cmd_map[robot_id] = move(
+ game,
+ motion_controller,
+ robot_id,
+ setup_point,
+ push_oren,
+ dribbling=False,
+ )
+ continue
+
+ if self._phase == "push":
+ if game.ts - self._phase_started_at <= self._PUSH_SECONDS:
+ self.blackboard.cmd_map[robot_id] = RobotCommand(
+ local_forward_vel=self._PUSH_SPEED,
+ local_left_vel=0,
+ angular_vel=0,
+ kick=0,
+ chip=0,
+ dribble=0,
+ )
+ continue
+ self._phase = "stop"
+ self._phase_started_at = game.ts
+
+ if self._phase == "stop":
+ self.blackboard.cmd_map[robot_id] = empty_command(False)
+ if game.ts - self._phase_started_at >= self._STOP_SECONDS:
+ self._phase = "approach"
+
+ return py_trees.common.Status.RUNNING
+
+ def _target_point(
+ self,
+ target_name: str,
+ ball_pos: Vector2D,
+ game,
+ half_length: float,
+ half_width: float,
+ ) -> Vector2D:
+ if target_name == "top":
+ return Vector2D(ball_pos.x, half_width + self._BOUNDARY_EPSILON)
+ if target_name == "bottom":
+ return Vector2D(ball_pos.x, -half_width - self._BOUNDARY_EPSILON)
+ if target_name == "left":
+ return Vector2D(-half_length - self._BOUNDARY_EPSILON, self._safe_touchline_y(ball_pos.y, game))
+ if target_name == "right":
+ return Vector2D(half_length + self._BOUNDARY_EPSILON, self._safe_touchline_y(ball_pos.y, game))
+
+ yellow_is_right = game.my_team_is_right == game.my_team_is_yellow
+ yellow_goal_x = half_length + self._GOAL_EPSILON if yellow_is_right else -half_length - self._GOAL_EPSILON
+ return Vector2D(yellow_goal_x, 0.0)
+
+ def _safe_touchline_y(self, current_y: float, game) -> float:
+ min_abs_y = game.field.half_goal_width + self._GOAL_MOUTH_CLEARANCE
+ sign = 1.0 if current_y >= 0.0 else -1.0
+ return sign * max(abs(current_y), min_abs_y)
+
+ def _has_reached_target(
+ self,
+ ball_pos: Vector2D,
+ target_name: str,
+ game,
+ half_length: float,
+ half_width: float,
+ ) -> bool:
+ if target_name == "top":
+ return ball_pos.y >= half_width - self._BOUNDARY_EPSILON
+ if target_name == "bottom":
+ return ball_pos.y <= -half_width + self._BOUNDARY_EPSILON
+ if target_name == "left":
+ return ball_pos.x <= -half_length + self._BOUNDARY_EPSILON and abs(ball_pos.y) > game.field.half_goal_width
+ if target_name == "right":
+ return ball_pos.x >= half_length - self._BOUNDARY_EPSILON and abs(ball_pos.y) > game.field.half_goal_width
+
+ yellow_is_right = game.my_team_is_right == game.my_team_is_yellow
+ if yellow_is_right:
+ return ball_pos.x >= half_length - self._GOAL_EPSILON and abs(ball_pos.y) < game.field.half_goal_width
+ return ball_pos.x <= -half_length + self._GOAL_EPSILON and abs(ball_pos.y) < game.field.half_goal_width
+
+ def _stop_all(self) -> py_trees.common.Status:
+ game = self.blackboard.game
+ for robot_id in game.friendly_robots:
+ self.blackboard.cmd_map[robot_id] = empty_command(False)
+ return py_trees.common.Status.RUNNING
+
+
+class DeliberateOutOfBoundsStrategy(AbstractStrategy):
+ """Opponent strategy that pushes the ball out once per side, then scores on yellow."""
+
+ def __init__(self, field_dims: FieldDimensions) -> None:
+ self._field_dims = field_dims
+ super().__init__()
+
+ def assert_exp_robots(self, n_runtime_friendly: int, n_runtime_enemy: int) -> bool:
+ return n_runtime_friendly >= 1
+
+ def assert_exp_goals(self, includes_my_goal_line: bool, includes_opp_goal_line: bool) -> bool:
+ return True
+
+ def get_min_bounding_req(self):
+ return None
+
+ def create_behaviour_tree(self) -> py_trees.behaviour.Behaviour:
+ root = py_trees.composites.Sequence(name="DeliberateOutOfBoundsRoot", memory=False)
+ root.add_child(DeliberateOutOfBoundsStep(self._field_dims))
+ return root
diff --git a/utama_core/strategy/referee/actions.py b/utama_core/strategy/referee/actions.py
index facc9093..09b19df8 100644
--- a/utama_core/strategy/referee/actions.py
+++ b/utama_core/strategy/referee/actions.py
@@ -27,7 +27,7 @@
)
from utama_core.entities.data.vector import Vector2D
from utama_core.entities.referee.referee_command import RefereeCommand
-from utama_core.skills.src.utils.move_utils import empty_command, move
+from utama_core.skills.src.utils.move_utils import empty_command, move, turn_on_spot
from utama_core.strategy.common.abstract_behaviour import AbstractBehaviour
@@ -301,6 +301,16 @@ class BallPlacementOursStep(AbstractBehaviour):
designated position. All other robots clear away from the ball.
"""
+ _RELEASE_DELAY_SECONDS = 0.25
+
+ def setup_(self):
+ self._release_started_at: float | None = None
+ self._placer_id: int | None = None
+
+ def _reset_release(self) -> None:
+ self._release_started_at = None
+ self._placer_id = None
+
def update(self) -> py_trees.common.Status:
game = self.blackboard.game
ref = game.referee
@@ -309,37 +319,67 @@ def update(self) -> py_trees.common.Status:
# Determine which team is ours
our_team = ref.yellow_team if game.my_team_is_yellow else ref.blue_team
if getattr(our_team, "can_place_ball", None) is False:
+ self._reset_release()
return _all_stop(self.blackboard)
target = ref.designated_position
if target is None:
+ self._reset_release()
return _all_stop(self.blackboard)
target_pos = Vector2D(target[0], target[1])
ball = game.ball
if ball is None:
+ self._reset_release()
return _all_stop(self.blackboard)
if ball.p.distance_to(target_pos) <= BALL_PLACEMENT_DONE_DISTANCE:
- return _all_stop(self.blackboard)
+ if self._release_started_at is None:
+ self._release_started_at = game.ts
+
+ placer_id = self._placer_id
+ if placer_id not in game.friendly_robots:
+ placer_id = min(
+ game.friendly_robots,
+ key=lambda rid: game.friendly_robots[rid].p.distance_to(ball.p),
+ )
+
+ hold_dribbler = game.ts - self._release_started_at < self._RELEASE_DELAY_SECONDS
+ for robot_id in game.friendly_robots:
+ self.blackboard.cmd_map[robot_id] = empty_command(hold_dribbler and robot_id == placer_id)
+ return py_trees.common.Status.RUNNING
+
+ self._release_started_at = None
# Pick the placer: robot closest to the ball
placer_id = min(
game.friendly_robots,
key=lambda rid: game.friendly_robots[rid].p.distance_to(ball.p),
)
+ self._placer_id = placer_id
+
+ _FACE_READY_ANGLE = 0.2
for robot_id in game.friendly_robots:
if robot_id == placer_id:
robot = game.friendly_robots[robot_id]
if robot.has_ball:
- target_for_move = target_pos
+ oren = robot.p.angle_to(target_pos)
+ face_error = math.atan2(math.sin(oren - robot.orientation), math.cos(oren - robot.orientation))
+ if abs(face_error) > _FACE_READY_ANGLE:
+ self.blackboard.cmd_map[robot_id] = turn_on_spot(
+ game, motion_controller, robot_id, oren, dribbling=True
+ )
+ else:
+ self.blackboard.cmd_map[robot_id] = move(
+ game, motion_controller, robot_id, target_pos, oren, dribbling=True
+ )
else:
target_for_move = Vector2D(ball.p.x, ball.p.y)
- oren = robot.p.angle_to(target_for_move)
- self.blackboard.cmd_map[robot_id] = move(
- game, motion_controller, robot_id, target_for_move, oren, dribbling=True
- )
+ oren = robot.p.angle_to(target_for_move)
+ self.blackboard.cmd_map[robot_id] = move(
+ game, motion_controller, robot_id, target_for_move, oren, dribbling=True
+ )
return _clear_to_legal_positions(
self.blackboard,
ball_keep_dist=BALL_KEEP_OUT_DISTANCE,
@@ -547,8 +587,26 @@ class DirectFreeOursStep(AbstractBehaviour):
All other robots stop in place.
"""
- # How far infield from the ball the approach point is placed
- _APPROACH_OFFSET = 0.15
+ # How close the robot center should get behind the ball before turning/kicking.
+ _APPROACH_OFFSET = ROBOT_RADIUS + 0.03
+ _APPROACH_READY_DISTANCE = 0.04
+ _KICK_READY_DISTANCE = 0.16
+ _FACE_READY_ANGLE = 0.18
+
+ @staticmethod
+ def _angle_error(current: float, target: float) -> float:
+ return math.atan2(math.sin(target - current), math.cos(target - current))
+
+ def _nearest_enemy_to_ball(self, game, ball_pos: Vector2D):
+ if not game.enemy_robots:
+ return None, None
+ enemy_id = min(game.enemy_robots, key=lambda rid: game.enemy_robots[rid].p.distance_to(ball_pos))
+ return enemy_id, game.enemy_robots[enemy_id]
+
+ def _kick_target_enemy(self, game, ball_pos: Vector2D):
+ if 1 in game.enemy_robots:
+ return 1, game.enemy_robots[1]
+ return self._nearest_enemy_to_ball(game, ball_pos)
def update(self) -> py_trees.common.Status:
game = self.blackboard.game
@@ -564,22 +622,33 @@ def update(self) -> py_trees.common.Status:
if robot_id == kicker_id and ball:
robot = game.friendly_robots[robot_id]
ball_pos = Vector2D(ball.p.x, ball.p.y)
+ distance_to_ball = robot.p.distance_to(ball_pos)
+ _, enemy = self._kick_target_enemy(game, ball_pos)
- # Compute an approach point offset inward from the nearest boundary,
- # so the robot never drives through the ball toward the edge.
- half_length = _field_half_length(game)
- half_width = _field_half_width(game)
- # Inward direction: push away from whichever boundary is closest
- offset_x = 0.0
- offset_y = 0.0
- if abs(ball_pos.x) > half_length - 0.5:
- offset_x = self._APPROACH_OFFSET * (-1.0 if ball_pos.x > 0 else 1.0)
- if abs(ball_pos.y) > half_width - 0.5:
- offset_y = self._APPROACH_OFFSET * (-1.0 if ball_pos.y > 0 else 1.0)
-
- approach = Vector2D(ball_pos.x + offset_x, ball_pos.y + offset_y)
- oren = robot.p.angle_to(approach)
- self.blackboard.cmd_map[robot_id] = move(game, motion_controller, robot_id, approach, oren)
+ if enemy is None:
+ target_oren = robot.p.angle_to(ball_pos)
+ else:
+ target_oren = ball_pos.angle_to(enemy.p)
+
+ kick_dir = Vector2D(math.cos(target_oren), math.sin(target_oren))
+ approach = Vector2D(
+ ball_pos.x - kick_dir.x * self._APPROACH_OFFSET,
+ ball_pos.y - kick_dir.y * self._APPROACH_OFFSET,
+ )
+ approach = _clamp_to_field(approach, game)
+ distance_to_approach = robot.p.distance_to(approach)
+ face_error = self._angle_error(robot.orientation, target_oren)
+
+ if distance_to_approach > self._APPROACH_READY_DISTANCE:
+ self.blackboard.cmd_map[robot_id] = move(game, motion_controller, robot_id, approach, target_oren)
+ elif abs(face_error) > self._FACE_READY_ANGLE:
+ self.blackboard.cmd_map[robot_id] = turn_on_spot(
+ game, motion_controller, robot_id, target_oren, dribbling=False
+ )
+ elif distance_to_ball > self._KICK_READY_DISTANCE:
+ self.blackboard.cmd_map[robot_id] = move(game, motion_controller, robot_id, approach, target_oren)
+ else:
+ self.blackboard.cmd_map[robot_id] = empty_command(False)
else:
self.blackboard.cmd_map[robot_id] = empty_command(False)
diff --git a/utama_core/team_controller/src/controllers/real/real_robot_controller.py b/utama_core/team_controller/src/controllers/real/real_robot_controller.py
index 278d7f0a..63feaf3d 100644
--- a/utama_core/team_controller/src/controllers/real/real_robot_controller.py
+++ b/utama_core/team_controller/src/controllers/real/real_robot_controller.py
@@ -16,6 +16,13 @@
TIMEOUT,
TIMESTEP,
)
+
+# Leaky-bucket dribbler thermal limiter.
+# The bucket accumulates real seconds while dribbling and drains at the same
+# rate while off. When full the dribbler is forced off until the bucket drains
+# to DRIBBLER_RESUME_SECONDS (hysteresis), preventing rapid on/off oscillation.
+DRIBBLER_MAX_ON_SECONDS: float = 30.0
+DRIBBLER_RESUME_SECONDS: float = 15.0 # must drain to 50% before re-enabling
from utama_core.entities.data.command import RobotCommand, RobotResponse
from utama_core.skills.src.utils.move_utils import empty_command
from utama_core.team_controller.src.controllers.common.robot_controller_abstract import (
@@ -24,7 +31,6 @@
logger = logging.getLogger(__name__)
-# NB: A major assumption is that the robot IDs are 0-5 for the friendly team.
MAX_VEL = REAL_PARAMS.MAX_VEL
MAX_ANGULAR_VEL = REAL_PARAMS.MAX_ANGULAR_VEL
@@ -44,19 +50,42 @@ class RealRobotController(AbstractRobotController):
n_robots (int): The number of robots in the team. Directly affects output buffer size. Default is 6.
"""
- def __init__(self, is_team_yellow: bool, n_friendly: int):
+ def __init__(
+ self,
+ is_team_yellow: bool,
+ n_friendly: int,
+ vision_to_cmd_mapping: Optional[Dict[int, int]] = None,
+ serial_port: Optional[Serial] = None,
+ ):
super().__init__(is_team_yellow, n_friendly)
- self._serial_port = self._init_serial()
+ self._serial_port = self._init_serial() if serial_port is None else serial_port
+ self._sharing_friendly_transmitter = serial_port is not None
self._rbt_cmd_size = 10 # packet size for one robot
self._out_packet = self._empty_command()
self._in_packet_size = 1 # size of the feedback packet received from the robots
self._robots_info: List[RobotResponse] = [None] * self._n_friendly
- logger.debug(f"Serial port: {PORT} opened with baudrate: {BAUD_RATE} and timeout {TIMEOUT}")
+ if serial_port is None:
+ logger.debug(f"Serial port: {PORT} opened with baudrate: {BAUD_RATE} and timeout {TIMEOUT}")
+ else:
+ logger.debug(f"Sharing serial port: {self._serial_port.port}")
self._assigned_mapping = {} # mapping of robot_id to index in the out_packet
+ self._vision_to_cmd_mapping = vision_to_cmd_mapping if vision_to_cmd_mapping is not None else {}
+ cmd_to_vision_mapping = {v: k for k, v in self._vision_to_cmd_mapping.items()}
+ if len(cmd_to_vision_mapping) != len(self._vision_to_cmd_mapping):
+ raise ValueError("vision_to_cmd_mapping must be one-to-one: duplicate command IDs are not allowed.")
+ self._cmd_to_vision_mapping = cmd_to_vision_mapping
# track last kick time for each robot to transmit kick as HIGH for n timesteps after command
self._kicker_tracker: Dict[int, KickTrackerEntry] = {}
+ # leaky-bucket dribbler thermal limiter: accumulated wall-clock seconds per
+ # robot (cmd ID). Fills while dribbling, drains while off, clamped to
+ # [0, DRIBBLER_MAX_ON_SECONDS]. Dribbler is forced off when bucket is full.
+ self._dribbler_seconds: Dict[int, float] = {}
+ self._dribbler_last_tick: Dict[int, float] = {} # time.monotonic() of last call
+ self._dribbler_limit_warned: set = set() # robots that have already been warned this event
+ self._dribbler_throttled: set = set() # robots currently in post-limit cooldown (hysteresis)
+
def get_robots_responses(self) -> List[RobotResponse]:
HEADER = 0xAA
FOOTER = 0x55
@@ -72,6 +101,7 @@ def get_robots_responses(self) -> List[RobotResponse]:
self._buffer.extend(self._serial_port.read(bytes_available))
responses = []
+ responded_ids = set()
while True:
# 2. Look for header
@@ -112,7 +142,14 @@ def get_robots_responses(self) -> List[RobotResponse]:
# Guard against IndexError just in case, though validated by 'length' check
if len(data) >= 1:
- responses.append(RobotResponse(robot_id, has_ball=(data[0] & 0x01) != 0))
+ # cmd IDs are returned as-is; StrategyRunner remaps them to vision IDs via _split_robot_responses_by_team
+ if robot_id in responded_ids:
+ warnings.warn(
+ f"Received multiple responses for robot ID {robot_id} in the same cycle. Ignoring subsequent responses."
+ )
+ else:
+ responses.append(RobotResponse(robot_id, has_ball=(data[0] & 0x01) != 0))
+ responded_ids.add(robot_id)
# 8. Clear parsed packet from buffer
del self._buffer[:packet_len]
@@ -163,6 +200,12 @@ def _add_robot_command(self, command: RobotCommand, robot_id: int) -> None:
robot_id (int): The ID of the robot.
command (RobotCommand): A named tuple containing the robot command with keys: 'local_forward_vel', 'local_left_vel', 'angular_vel', 'kick', 'chip', 'dribble'.
"""
+ if robot_id in self._vision_to_cmd_mapping:
+ robot_id = self._vision_to_cmd_mapping[robot_id]
+ elif self._sharing_friendly_transmitter:
+ warnings.warn(
+ f"Robot ID {robot_id} has no entry in vision_to_cmd_mapping on a shared transmitter setup; command will be sent using the raw vision ID."
+ )
if robot_id in self._assigned_mapping:
warnings.warn(
f"Robot ID {robot_id} has already been assigned a command in this cycle. Overwriting previous command."
@@ -211,10 +254,54 @@ def _add_robot_command(self, command: RobotCommand, robot_id: int) -> None:
# self._robots_info[i] = info
# data_in = data_in << 1 # shift to the next robot's data
+ def _update_dribbler_bucket(self, robot_id: int, requested: bool) -> bool:
+ """Leaky-bucket thermal limiter for the dribbler.
+
+ Returns True if the dribbler should actually be turned on this call.
+ The bucket accumulates real elapsed seconds while dribbling and drains
+ at the same rate while off, so intermittent use is accounted for
+ proportionally regardless of control-loop frequency.
+ """
+ now = time.monotonic()
+ dt = now - self._dribbler_last_tick.get(robot_id, now)
+ self._dribbler_last_tick[robot_id] = now
+
+ current = self._dribbler_seconds.get(robot_id, 0.0)
+ at_limit = current >= DRIBBLER_MAX_ON_SECONDS
+
+ if at_limit:
+ self._dribbler_throttled.add(robot_id)
+
+ throttled = robot_id in self._dribbler_throttled
+
+ if requested and not throttled:
+ self._dribbler_seconds[robot_id] = min(current + dt, DRIBBLER_MAX_ON_SECONDS)
+ self._dribbler_limit_warned.discard(robot_id)
+ return True
+ elif requested and throttled:
+ if robot_id not in self._dribbler_limit_warned:
+ warnings.warn(
+ f"Robot {robot_id}: dribbler thermal limit reached "
+ f"({DRIBBLER_MAX_ON_SECONDS:.0f}s). "
+ f"Forcing dribbler off until bucket drains to {DRIBBLER_RESUME_SECONDS:.0f}s."
+ )
+ self._dribbler_limit_warned.add(robot_id)
+ # still drain while forced off
+ self._dribbler_seconds[robot_id] = max(0.0, current - dt)
+ if self._dribbler_seconds[robot_id] <= DRIBBLER_RESUME_SECONDS:
+ self._dribbler_throttled.discard(robot_id)
+ self._dribbler_limit_warned.discard(robot_id)
+ return False
+ else:
+ # Not requested: drain the bucket
+ self._dribbler_seconds[robot_id] = max(0.0, current - dt)
+ if self._dribbler_seconds[robot_id] <= DRIBBLER_RESUME_SECONDS:
+ self._dribbler_throttled.discard(robot_id)
+ self._dribbler_limit_warned.discard(robot_id)
+ return False
+
def _generate_command_buffer(self, robot_id: int, c_command: RobotCommand) -> bytes:
"""Generates the command buffer to be sent to the robot."""
- assert robot_id < 6, "Invalid robot_id. Must be between 0 and 5."
-
# endianness: little endian
packet = bytearray(
[
@@ -228,8 +315,10 @@ def _generate_command_buffer(self, robot_id: int, c_command: RobotCommand) -> by
]
)
+ dribble_allowed = self._update_dribbler_bucket(robot_id, c_command.dribble)
+
dribbler_speed = 0
- if c_command.dribble:
+ if dribble_allowed:
dribbler_speed = 0xC000 # set bits 15:14 to 11
dribbler_speed |= 4095 & 0x3FFF # set bits 13:0 to 4095
@@ -372,6 +461,18 @@ def n_friendly(self) -> int:
def in_packet_size(self) -> int:
return self._in_packet_size
+ @property
+ def vision_to_cmd_mapping(self) -> Dict[int, int]:
+ return self._vision_to_cmd_mapping
+
+ @property
+ def cmd_to_vision_mapping(self) -> Dict[int, int]:
+ return self._cmd_to_vision_mapping
+
+ @property
+ def sharing_friendly_transmitter(self) -> bool:
+ return self._sharing_friendly_transmitter
+
if __name__ == "__main__":
robot_controller = RealRobotController(is_team_yellow=True, n_friendly=1)
diff --git a/utama_core/team_controller/src/debug_utils/telop_gui.py b/utama_core/team_controller/src/debug_utils/telop_gui.py
index 79ce2ab3..5da99738 100644
--- a/utama_core/team_controller/src/debug_utils/telop_gui.py
+++ b/utama_core/team_controller/src/debug_utils/telop_gui.py
@@ -53,6 +53,7 @@ class RealRobotController:
def __init__(self, is_team_yellow=True, n_friendly=2):
self.n_friendly = n_friendly
+ self._dribbler_seconds: dict = {}
print("\n[DUMMY CONTROLLER ENABLED]")
print("No serial/network/hardware required.\n")
@@ -89,6 +90,7 @@ def send_robot_commands(self):
KICK_C = "#d94a4a"
ON_C = "#4ad97a"
BALL_C = "#d9a84a"
+HEAT_C = "#d94a4a" # Red when dribbler near/at thermal limit
DUMMY_C = "#d9a84a" # Orange for dummy warning
@@ -287,6 +289,8 @@ def _build_ui(self):
self._fb_ball_vars: dict[int, tk.StringVar] = {}
self._fb_ball_lbls: dict[int, tk.Label] = {}
+ self._fb_heat_vars: dict[int, tk.StringVar] = {}
+ self._fb_heat_lbls: dict[int, tk.Label] = {}
self._fb_status_vars: dict[int, tk.StringVar] = {}
for i in range(N_FRIENDLY):
row = tk.Frame(fb_frame, bg=SURFACE)
@@ -315,6 +319,18 @@ def _build_ui(self):
)
ball_lbl.pack(side="left", padx=(8, 0))
+ heat_var = tk.StringVar(value="heat: 0%")
+ heat_lbl = tk.Label(
+ row,
+ textvariable=heat_var,
+ bg=SURFACE,
+ fg=MUTED,
+ font=("monospace", 11),
+ width=10,
+ anchor="w",
+ )
+ heat_lbl.pack(side="left", padx=(8, 0))
+
status_var = tk.StringVar(value="no data")
tk.Label(
row,
@@ -327,6 +343,8 @@ def _build_ui(self):
self._fb_ball_vars[i] = ball_var
self._fb_ball_lbls[i] = ball_lbl
+ self._fb_heat_vars[i] = heat_var
+ self._fb_heat_lbls[i] = heat_lbl
self._fb_status_vars[i] = status_var
# --- Command readout ---
@@ -485,6 +503,10 @@ def _control_loop(self):
time.sleep(max(0.0, dt - elapsed))
def _update_feedback(self, responses):
+ from utama_core.team_controller.src.controllers.real.real_robot_controller import (
+ DRIBBLER_MAX_ON_SECONDS,
+ )
+
for resp in responses:
if resp.id not in self._fb_ball_vars:
continue
@@ -496,6 +518,13 @@ def _update_feedback(self, responses):
self._fb_ball_lbls[resp.id].configure(fg=MUTED)
self._fb_status_vars[resp.id].set("connected")
+ # dribbler heat display
+ bucket = self.controller._dribbler_seconds.get(resp.id, 0.0)
+ pct = int(bucket / DRIBBLER_MAX_ON_SECONDS * 100)
+ self._fb_heat_vars[resp.id].set(f"heat: {pct:3d}%")
+ heat_colour = HEAT_C if pct >= 80 else (BALL_C if pct >= 50 else MUTED)
+ self._fb_heat_lbls[resp.id].configure(fg=heat_colour)
+
def _update_readout(self, cmd: RobotCommand):
self._metrics["fwd"].set(f"{cmd.local_forward_vel:+.2f}")
self._metrics["left"].set(f"{cmd.local_left_vel:+.2f}")
diff --git a/utama_core/tests/controller/test_dribbler_limiter.py b/utama_core/tests/controller/test_dribbler_limiter.py
new file mode 100644
index 00000000..2ed97bcd
--- /dev/null
+++ b/utama_core/tests/controller/test_dribbler_limiter.py
@@ -0,0 +1,177 @@
+"""Tests for the leaky-bucket dribbler thermal limiter in RealRobotController."""
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from utama_core.team_controller.src.controllers.real.real_robot_controller import (
+ DRIBBLER_MAX_ON_SECONDS,
+ DRIBBLER_RESUME_SECONDS,
+ RealRobotController,
+)
+
+_MONOTONIC = "utama_core.team_controller.src.controllers.real.real_robot_controller.time.monotonic"
+
+
+@pytest.fixture
+def controller():
+ """RealRobotController with a mock serial port (no real hardware needed)."""
+ mock_serial = MagicMock()
+ mock_serial.in_waiting = 0
+ return RealRobotController(is_team_yellow=True, n_friendly=3, serial_port=mock_serial)
+
+
+def _tick(controller, robot_id: int, requested: bool, dt: float) -> bool:
+ """Call _update_dribbler_bucket with a controlled time delta.
+
+ Seeds _dribbler_last_tick so that time.monotonic() - last_tick == dt exactly.
+ """
+ now = controller._dribbler_last_tick.get(robot_id, 0.0) + dt
+ controller._dribbler_last_tick[robot_id] = now - dt
+ with patch(_MONOTONIC, return_value=now):
+ return controller._update_dribbler_bucket(robot_id, requested)
+
+
+# ---------------------------------------------------------------------------
+# Basic bucket mechanics
+# ---------------------------------------------------------------------------
+
+
+def test_dribbler_allowed_when_bucket_empty(controller):
+ assert _tick(controller, 0, requested=True, dt=1.0) is True
+
+
+def test_bucket_fills_by_elapsed_seconds(controller):
+ _tick(controller, 0, requested=True, dt=5.0)
+ assert controller._dribbler_seconds[0] == pytest.approx(5.0)
+
+
+def test_bucket_drains_by_elapsed_seconds(controller):
+ controller._dribbler_seconds[0] = 20.0
+ _tick(controller, 0, requested=False, dt=7.0)
+ assert controller._dribbler_seconds[0] == pytest.approx(13.0)
+
+
+def test_bucket_floors_at_zero(controller):
+ controller._dribbler_seconds[0] = 2.0
+ _tick(controller, 0, requested=False, dt=10.0)
+ assert controller._dribbler_seconds[0] == pytest.approx(0.0)
+
+
+def test_dribbler_off_returns_false(controller):
+ assert _tick(controller, 0, requested=False, dt=1.0) is False
+
+
+def test_bucket_does_not_overfill(controller):
+ _tick(controller, 0, requested=True, dt=DRIBBLER_MAX_ON_SECONDS * 2)
+ assert controller._dribbler_seconds[0] == pytest.approx(DRIBBLER_MAX_ON_SECONDS)
+
+
+# ---------------------------------------------------------------------------
+# Limit enforcement
+# ---------------------------------------------------------------------------
+
+
+def test_dribbler_forced_off_at_limit(controller):
+ controller._dribbler_seconds[0] = DRIBBLER_MAX_ON_SECONDS
+ assert _tick(controller, 0, requested=True, dt=1.0) is False
+
+
+def test_dribbler_forced_off_emits_warning_once(controller):
+ controller._dribbler_seconds[0] = DRIBBLER_MAX_ON_SECONDS
+ # first tick at limit: warning
+ with pytest.warns(UserWarning, match="thermal limit"):
+ _tick(controller, 0, requested=True, dt=1.0)
+ # subsequent ticks: no more warnings
+ import warnings as _warnings
+
+ with _warnings.catch_warnings():
+ _warnings.simplefilter("error")
+ _tick(controller, 0, requested=True, dt=1.0) # must not raise
+
+
+# ---------------------------------------------------------------------------
+# Hysteresis: no 1s-on / 1s-off oscillation at the limit
+# ---------------------------------------------------------------------------
+
+
+def test_throttled_robot_stays_off_until_resume_threshold(controller):
+ # fill to limit
+ controller._dribbler_seconds[0] = DRIBBLER_MAX_ON_SECONDS
+ _tick(controller, 0, requested=True, dt=0.0) # trigger throttle flag
+
+ # drain to just above resume threshold — still blocked
+ controller._dribbler_seconds[0] = DRIBBLER_RESUME_SECONDS + 1.0
+ assert _tick(controller, 0, requested=True, dt=0.0) is False
+
+
+def test_throttled_robot_resumes_at_resume_threshold(controller):
+ controller._dribbler_seconds[0] = DRIBBLER_MAX_ON_SECONDS
+ _tick(controller, 0, requested=True, dt=0.0) # trigger throttle
+
+ # drain to exactly the resume threshold
+ _tick(controller, 0, requested=False, dt=DRIBBLER_MAX_ON_SECONDS - DRIBBLER_RESUME_SECONDS)
+ assert controller._dribbler_seconds[0] == pytest.approx(DRIBBLER_RESUME_SECONDS)
+ # now requesting dribble should be allowed
+ assert _tick(controller, 0, requested=True, dt=1.0) is True
+
+
+def test_bucket_drains_while_throttled(controller):
+ # robot should still drain even while strategy keeps requesting dribble
+ controller._dribbler_seconds[0] = DRIBBLER_MAX_ON_SECONDS
+ _tick(controller, 0, requested=True, dt=0.0) # throttle
+ _tick(controller, 0, requested=True, dt=5.0)
+ assert controller._dribbler_seconds[0] == pytest.approx(DRIBBLER_MAX_ON_SECONDS - 5.0)
+
+
+def test_no_oscillation_at_limit(controller):
+ # fill to limit, then alternate on/off — robot must stay off until resume threshold
+ controller._dribbler_seconds[0] = DRIBBLER_MAX_ON_SECONDS
+ results = []
+ for i in range(10):
+ requested = i % 2 == 0 # alternate on/off
+ results.append(_tick(controller, 0, requested=requested, dt=1.0))
+ # all must be False until bucket drains below DRIBBLER_RESUME_SECONDS
+ assert all(r is False for r in results)
+
+
+# ---------------------------------------------------------------------------
+# Recovery after full drain
+# ---------------------------------------------------------------------------
+
+
+def test_dribbler_fully_recovers_after_drain(controller):
+ controller._dribbler_seconds[0] = DRIBBLER_MAX_ON_SECONDS
+ _tick(controller, 0, requested=True, dt=0.0) # throttle
+ _tick(controller, 0, requested=False, dt=DRIBBLER_MAX_ON_SECONDS)
+ assert controller._dribbler_seconds[0] == pytest.approx(0.0)
+ assert _tick(controller, 0, requested=True, dt=1.0) is True
+
+
+def test_limit_hit_at_correct_wall_time(controller):
+ # 29s → allowed, fills to 29s
+ assert _tick(controller, 0, requested=True, dt=29.0) is True
+ assert controller._dribbler_seconds[0] == pytest.approx(29.0)
+ # 1s more fills to 30s (cap) → still allowed (not yet at limit at entry)
+ assert _tick(controller, 0, requested=True, dt=1.0) is True
+ assert controller._dribbler_seconds[0] == pytest.approx(DRIBBLER_MAX_ON_SECONDS)
+ # next tick: at limit → forced off
+ assert _tick(controller, 0, requested=True, dt=1.0) is False
+
+
+# ---------------------------------------------------------------------------
+# Per-robot isolation
+# ---------------------------------------------------------------------------
+
+
+def test_buckets_are_independent_per_robot(controller):
+ controller._dribbler_seconds[0] = DRIBBLER_MAX_ON_SECONDS
+ assert _tick(controller, 0, requested=True, dt=0.0) is False # throttle robot 0
+ assert _tick(controller, 1, requested=True, dt=1.0) is True
+
+
+def test_draining_one_robot_does_not_affect_another(controller):
+ controller._dribbler_seconds[0] = 20.0
+ controller._dribbler_seconds[1] = 10.0
+ _tick(controller, 0, requested=False, dt=5.0)
+ assert controller._dribbler_seconds[1] == pytest.approx(10.0)
diff --git a/utama_core/tests/custom_referee/test_custom_referee.py b/utama_core/tests/custom_referee/test_custom_referee.py
index 1fb17557..9819cdfd 100644
--- a/utama_core/tests/custom_referee/test_custom_referee.py
+++ b/utama_core/tests/custom_referee/test_custom_referee.py
@@ -1,581 +1,582 @@
-"""Unit tests for the CustomReferee system."""
-
-from __future__ import annotations
-
-import math
-from unittest.mock import MagicMock
-
-import pytest
-
-from utama_core.config.field_params import STANDARD_FIELD_DIMS
-from utama_core.custom_referee.custom_referee import CustomReferee
-from utama_core.custom_referee.geometry import RefereeGeometry
-from utama_core.custom_referee.profiles.profile_loader import load_profile
-from utama_core.custom_referee.rules.defense_area_rule import DefenseAreaRule
-from utama_core.custom_referee.rules.goal_rule import GoalRule
-from utama_core.custom_referee.rules.keep_out_rule import KeepOutRule
-from utama_core.custom_referee.rules.out_of_bounds_rule import OutOfBoundsRule
-from utama_core.custom_referee.state_machine import GameStateMachine
-from utama_core.entities.data.referee import RefereeData
-from utama_core.entities.data.vector import Vector2D, Vector3D
-from utama_core.entities.game.ball import Ball
-from utama_core.entities.game.game_frame import GameFrame
-from utama_core.entities.game.robot import Robot
-from utama_core.entities.referee.referee_command import RefereeCommand
-from utama_core.entities.referee.stage import Stage
-
-# ---------------------------------------------------------------------------
-# Helpers
-# ---------------------------------------------------------------------------
-
-GEO = RefereeGeometry.from_field_dims(STANDARD_FIELD_DIMS)
-
-
-def _ball(x: float, y: float, z: float = 0.0) -> Ball:
- return Ball(p=Vector3D(x, y, z), v=Vector3D(0, 0, 0), a=Vector3D(0, 0, 0))
-
-
-def _robot(robot_id: int, x: float, y: float, is_friendly: bool, has_ball: bool = False) -> Robot:
- return Robot(
- id=robot_id,
- is_friendly=is_friendly,
- has_ball=has_ball,
- p=Vector2D(x, y),
- v=Vector2D(0, 0),
- a=Vector2D(0, 0),
- orientation=0.0,
- )
-
-
-def _frame(
- ball: Ball,
- friendly_robots: dict | None = None,
- enemy_robots: dict | None = None,
- my_team_is_yellow: bool = True,
- my_team_is_right: bool = False,
- ts: float = 10.0,
-) -> GameFrame:
- return GameFrame(
- ts=ts,
- my_team_is_yellow=my_team_is_yellow,
- my_team_is_right=my_team_is_right,
- friendly_robots=friendly_robots or {},
- enemy_robots=enemy_robots or {},
- ball=ball,
- referee=None,
- )
-
-
-def _state_machine() -> GameStateMachine:
- sm = GameStateMachine(
- half_duration_seconds=300.0,
- kickoff_team="yellow",
- n_robots_yellow=3,
- n_robots_blue=3,
- )
- sm.seed_clock(0.0)
- sm.set_command(RefereeCommand.NORMAL_START, 0.0)
- return sm
-
-
-# ---------------------------------------------------------------------------
-# GoalRule
-# ---------------------------------------------------------------------------
-
-
-class TestGoalRule:
- def test_right_goal_blue_scores_when_yellow_is_right(self):
- # Yellow defends right goal → ball in right goal → blue scored → yellow kicks off.
- rule = GoalRule(cooldown_seconds=1.0)
- frame = _frame(ball=_ball(5.0, 0.0), my_team_is_yellow=True, my_team_is_right=True)
- violation = rule.check(frame, GEO, RefereeCommand.NORMAL_START)
- assert violation is not None
- assert violation.rule_name == "goal"
- assert violation.status_message == "Goal by Blue"
- assert violation.next_command == RefereeCommand.PREPARE_KICKOFF_YELLOW
-
- def test_left_goal_yellow_scores_when_yellow_is_right(self):
- # Blue defends left goal → ball in left goal → yellow scored → blue kicks off.
- rule = GoalRule(cooldown_seconds=1.0)
- frame = _frame(ball=_ball(-5.0, 0.0), my_team_is_yellow=True, my_team_is_right=True)
- violation = rule.check(frame, GEO, RefereeCommand.NORMAL_START)
- assert violation is not None
- assert violation.status_message == "Goal by Yellow"
- assert violation.next_command == RefereeCommand.PREPARE_KICKOFF_BLUE
-
- def test_right_goal_yellow_scores_when_yellow_is_left(self):
- # Blue defends right goal → ball in right goal → yellow scored → blue kicks off.
- rule = GoalRule(cooldown_seconds=1.0)
- frame = _frame(ball=_ball(5.0, 0.0), my_team_is_yellow=True, my_team_is_right=False)
- violation = rule.check(frame, GEO, RefereeCommand.NORMAL_START)
- assert violation is not None
- assert violation.status_message == "Goal by Yellow"
- assert violation.next_command == RefereeCommand.PREPARE_KICKOFF_BLUE
-
- def test_no_goal_wide_shot(self):
- rule = GoalRule()
- frame = _frame(ball=_ball(5.0, 1.0)) # y=1.0 > half_goal_width=0.5
- assert rule.check(frame, GEO, RefereeCommand.NORMAL_START) is None
-
- def test_goal_respects_cooldown(self):
- rule = GoalRule(cooldown_seconds=2.0)
- frame1 = _frame(ball=_ball(5.0, 0.0), my_team_is_right=True, ts=10.0)
- v1 = rule.check(frame1, GEO, RefereeCommand.NORMAL_START)
- assert v1 is not None
-
- # Second detection within cooldown window — must be suppressed.
- frame2 = _frame(ball=_ball(5.0, 0.0), my_team_is_right=True, ts=10.5)
- v2 = rule.check(frame2, GEO, RefereeCommand.NORMAL_START)
- assert v2 is None
-
- # After cooldown expires — should fire again.
- frame3 = _frame(ball=_ball(5.0, 0.0), my_team_is_right=True, ts=13.0)
- v3 = rule.check(frame3, GEO, RefereeCommand.NORMAL_START)
- assert v3 is not None
-
- def test_no_detection_during_stop(self):
- rule = GoalRule()
- frame = _frame(ball=_ball(5.0, 0.0))
- assert rule.check(frame, GEO, RefereeCommand.STOP) is None
-
-
-# ---------------------------------------------------------------------------
-# OutOfBoundsRule
-# ---------------------------------------------------------------------------
-
-
-class TestOutOfBoundsRule:
- def test_ball_out_top(self):
- rule = OutOfBoundsRule()
- frame = _frame(ball=_ball(0.0, 3.5), my_team_is_yellow=True)
- violation = rule.check(frame, GEO, RefereeCommand.NORMAL_START)
- assert violation is not None
- assert violation.rule_name == "out_of_bounds"
-
- def test_ball_out_right_side(self):
- rule = OutOfBoundsRule()
- frame = _frame(ball=_ball(5.0, 1.0)) # wide — not in goal (y=1.0 > 0.5)
- violation = rule.check(frame, GEO, RefereeCommand.NORMAL_START)
- assert violation is not None
-
- def test_ball_in_field_no_violation(self):
- rule = OutOfBoundsRule()
- frame = _frame(ball=_ball(0.0, 0.0))
- assert rule.check(frame, GEO, RefereeCommand.NORMAL_START) is None
-
- def test_ball_in_goal_no_out_of_bounds(self):
- rule = OutOfBoundsRule()
- frame = _frame(ball=_ball(5.0, 0.0)) # in right goal
- # GoalRule handles this; OutOfBoundsRule must not also fire.
- assert rule.check(frame, GEO, RefereeCommand.NORMAL_START) is None
-
- def test_free_kick_assigned_to_non_touching_team_friendly_yellow(self):
- """Friendly (yellow) last touched → enemy (blue) gets free kick."""
- rule = OutOfBoundsRule()
- friendly = {0: _robot(0, 4.4, 2.9, is_friendly=True, has_ball=True)}
- frame_before = _frame(ball=_ball(4.4, 2.9), friendly_robots=friendly, my_team_is_yellow=True, ts=9.9)
- rule.check(frame_before, GEO, RefereeCommand.NORMAL_START)
-
- frame_out = _frame(ball=_ball(0.0, 3.5), my_team_is_yellow=True, ts=10.0)
- violation = rule.check(frame_out, GEO, RefereeCommand.NORMAL_START)
- assert violation is not None
- assert violation.next_command == RefereeCommand.DIRECT_FREE_BLUE
-
- def test_designated_position_is_infield(self):
- rule = OutOfBoundsRule()
- frame = _frame(ball=_ball(0.0, 3.5))
- violation = rule.check(frame, GEO, RefereeCommand.NORMAL_START)
- assert violation is not None
- px, py = violation.designated_position
- assert abs(py) < GEO.half_width # placed infield
-
-
-# ---------------------------------------------------------------------------
-# DefenseAreaRule
-# ---------------------------------------------------------------------------
-
-
-class TestDefenseAreaRule:
- def _frame_with_attacker_in_defense(self, my_team_is_right: bool = False) -> GameFrame:
- # Enemy robot inside my (left) defense area.
- enemy = {0: _robot(0, -4.3, 0.5, is_friendly=False)}
- return _frame(
- ball=_ball(0, 0),
- enemy_robots=enemy,
- my_team_is_right=my_team_is_right,
- )
-
- def test_fires_during_normal_start(self):
- rule = DefenseAreaRule()
- frame = self._frame_with_attacker_in_defense(my_team_is_right=False)
- v = rule.check(frame, GEO, RefereeCommand.NORMAL_START)
- assert v is not None
- assert v.rule_name == "defense_area"
-
- def test_does_not_fire_during_stop(self):
- rule = DefenseAreaRule()
- frame = self._frame_with_attacker_in_defense()
- assert rule.check(frame, GEO, RefereeCommand.STOP) is None
-
- def test_does_not_fire_during_force_start_no_actually_fires(self):
- rule = DefenseAreaRule()
- frame = self._frame_with_attacker_in_defense(my_team_is_right=False)
- v = rule.check(frame, GEO, RefereeCommand.FORCE_START)
- assert v is not None
-
- def test_too_many_defenders(self):
- rule = DefenseAreaRule(max_defenders=1)
- # Two friendly robots in own (left) defense area.
- friendly = {
- 0: _robot(0, -4.3, 0.0, is_friendly=True),
- 1: _robot(1, -4.3, 0.5, is_friendly=True),
- }
- frame = _frame(ball=_ball(0, 0), friendly_robots=friendly, my_team_is_right=False, my_team_is_yellow=True)
- v = rule.check(frame, GEO, RefereeCommand.NORMAL_START)
- assert v is not None
- assert v.next_command == RefereeCommand.DIRECT_FREE_BLUE
-
-
-# ---------------------------------------------------------------------------
-# KeepOutRule
-# ---------------------------------------------------------------------------
-
-
-class TestKeepOutRule:
- def test_no_trigger_before_persistence_threshold(self):
- rule = KeepOutRule(radius_meters=0.5, violation_persistence_frames=5)
- friendly = {0: _robot(0, 0.2, 0.0, is_friendly=True)}
- # Enemy is kicking → check friendly.
- for _ in range(4):
- frame = _frame(ball=_ball(0.0, 0.0), friendly_robots=friendly)
- v = rule.check(frame, GEO, RefereeCommand.DIRECT_FREE_BLUE)
- assert v is None
-
- def test_triggers_after_persistence_threshold(self):
- rule = KeepOutRule(radius_meters=0.5, violation_persistence_frames=5)
- friendly = {0: _robot(0, 0.2, 0.0, is_friendly=True)}
- v = None
- for _ in range(5):
- frame = _frame(ball=_ball(0.0, 0.0), friendly_robots=friendly)
- v = rule.check(frame, GEO, RefereeCommand.DIRECT_FREE_BLUE)
- assert v is not None
- assert v.rule_name == "keep_out"
-
- def test_resets_on_non_violation_frame(self):
- rule = KeepOutRule(radius_meters=0.5, violation_persistence_frames=5)
- friendly = {0: _robot(0, 0.2, 0.0, is_friendly=True)}
- for _ in range(4):
- frame = _frame(ball=_ball(0.0, 0.0), friendly_robots=friendly)
- rule.check(frame, GEO, RefereeCommand.DIRECT_FREE_BLUE)
-
- # Robot moves away — count resets.
- far_frame = _frame(
- ball=_ball(0.0, 0.0),
- friendly_robots={0: _robot(0, 2.0, 0.0, is_friendly=True)},
- )
- rule.check(far_frame, GEO, RefereeCommand.DIRECT_FREE_BLUE)
-
- # Needs another full persistence run to trigger.
- v = None
- for _ in range(5):
- frame = _frame(ball=_ball(0.0, 0.0), friendly_robots=friendly)
- v = rule.check(frame, GEO, RefereeCommand.DIRECT_FREE_BLUE)
- assert v is not None
-
- def test_inactive_during_normal_start(self):
- rule = KeepOutRule(radius_meters=0.5, violation_persistence_frames=1)
- friendly = {0: _robot(0, 0.1, 0.0, is_friendly=True)}
- frame = _frame(ball=_ball(0.0, 0.0), friendly_robots=friendly)
- assert rule.check(frame, GEO, RefereeCommand.NORMAL_START) is None
-
-
-# ---------------------------------------------------------------------------
-# GameStateMachine
-# ---------------------------------------------------------------------------
-
-
-class TestGameStateMachine:
- def test_goal_increments_yellow_score(self):
- from utama_core.custom_referee.rules.base_rule import RuleViolation
-
- sm = _state_machine()
- violation = RuleViolation(
- rule_name="goal",
- suggested_command=RefereeCommand.STOP,
- next_command=RefereeCommand.PREPARE_KICKOFF_BLUE,
- status_message="Goal by Yellow",
- )
- data = sm.step(current_time=10.0, violation=violation)
- assert sm.yellow_team.score == 1
- assert sm.blue_team.score == 0
- assert data.referee_command == RefereeCommand.STOP
- assert data.next_command == RefereeCommand.PREPARE_KICKOFF_BLUE
-
- def test_goal_increments_blue_score(self):
- from utama_core.custom_referee.rules.base_rule import RuleViolation
-
- sm = _state_machine()
- violation = RuleViolation(
- rule_name="goal",
- suggested_command=RefereeCommand.STOP,
- next_command=RefereeCommand.PREPARE_KICKOFF_YELLOW,
- status_message="Goal by Blue",
- )
- sm.step(current_time=10.0, violation=violation)
- assert sm.blue_team.score == 1
- assert sm.yellow_team.score == 0
-
- def test_transition_cooldown_suppresses_duplicate(self):
- from utama_core.custom_referee.rules.base_rule import RuleViolation
-
- sm = _state_machine()
- violation = RuleViolation(
- rule_name="goal",
- suggested_command=RefereeCommand.STOP,
- next_command=RefereeCommand.PREPARE_KICKOFF_BLUE,
- status_message="Goal",
- )
- sm.step(current_time=10.0, violation=violation)
- assert sm.yellow_team.score == 1
-
- # Second goal within cooldown window — must be suppressed.
- sm.step(current_time=10.1, violation=violation)
- assert sm.yellow_team.score == 1 # still 1
-
- def test_goal_sets_designated_position_to_centre(self):
- from utama_core.custom_referee.rules.base_rule import RuleViolation
-
- sm = _state_machine()
- violation = RuleViolation(
- rule_name="goal",
- suggested_command=RefereeCommand.STOP,
- next_command=RefereeCommand.PREPARE_KICKOFF_BLUE,
- status_message="Goal by Yellow",
- )
- data = sm.step(current_time=10.0, violation=violation)
- assert data.designated_position == (0.0, 0.0)
-
- def test_goal_status_message_is_propagated_into_referee_data(self):
- from utama_core.custom_referee.rules.base_rule import RuleViolation
-
- sm = _state_machine()
- violation = RuleViolation(
- rule_name="goal",
- suggested_command=RefereeCommand.STOP,
- next_command=RefereeCommand.PREPARE_KICKOFF_BLUE,
- status_message="Goal by Yellow",
- )
- data = sm.step(current_time=10.0, violation=violation)
- assert data.status_message == "Goal by Yellow"
-
- def test_manual_command_clears_status_message(self):
- from utama_core.custom_referee.rules.base_rule import RuleViolation
-
- sm = _state_machine()
- violation = RuleViolation(
- rule_name="goal",
- suggested_command=RefereeCommand.STOP,
- next_command=RefereeCommand.PREPARE_KICKOFF_BLUE,
- status_message="Goal by Yellow",
- )
- sm.step(current_time=10.0, violation=violation)
-
- sm.set_command(RefereeCommand.NORMAL_START, timestamp=11.0)
- data = sm.step(current_time=11.0, violation=None)
- assert data.status_message is None
-
- def test_manual_set_command(self):
- sm = _state_machine()
- sm.set_command(RefereeCommand.NORMAL_START, timestamp=5.0)
- assert sm.command == RefereeCommand.NORMAL_START
-
- def test_manual_ball_placement_auto_advances_from_stop_and_then_to_normal_start(self):
- sm = _state_machine()
- sm.set_command(RefereeCommand.BALL_PLACEMENT_YELLOW, timestamp=1.0)
- sm.ball_placement_target = (0.0, 0.0)
-
- clear_frame = _frame(ball=_ball(2.0, 0.0), ts=10.0)
- data = sm.step(current_time=10.0, violation=None, game_frame=clear_frame)
+"""Unit tests for the CustomReferee system."""
+
+from __future__ import annotations
+
+import math
+from unittest.mock import MagicMock
+
+import pytest
+
+from utama_core.config.field_params import STANDARD_FIELD_DIMS
+from utama_core.custom_referee.custom_referee import CustomReferee
+from utama_core.custom_referee.geometry import RefereeGeometry
+from utama_core.custom_referee.profiles.profile_loader import load_profile
+from utama_core.custom_referee.rules.defense_area_rule import DefenseAreaRule
+from utama_core.custom_referee.rules.goal_rule import GoalRule
+from utama_core.custom_referee.rules.keep_out_rule import KeepOutRule
+from utama_core.custom_referee.rules.out_of_bounds_rule import OutOfBoundsRule
+from utama_core.custom_referee.state_machine import GameStateMachine
+from utama_core.entities.data.referee import RefereeData
+from utama_core.entities.data.vector import Vector2D, Vector3D
+from utama_core.entities.game.ball import Ball
+from utama_core.entities.game.game_frame import GameFrame
+from utama_core.entities.game.robot import Robot
+from utama_core.entities.referee.referee_command import RefereeCommand
+from utama_core.entities.referee.stage import Stage
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+GEO = RefereeGeometry.from_field_dims(STANDARD_FIELD_DIMS)
+
+
+def _ball(x: float, y: float, z: float = 0.0) -> Ball:
+ return Ball(p=Vector3D(x, y, z), v=Vector3D(0, 0, 0), a=Vector3D(0, 0, 0))
+
+
+def _robot(robot_id: int, x: float, y: float, is_friendly: bool, has_ball: bool = False) -> Robot:
+ return Robot(
+ id=robot_id,
+ is_friendly=is_friendly,
+ has_ball=has_ball,
+ p=Vector2D(x, y),
+ v=Vector2D(0, 0),
+ a=Vector2D(0, 0),
+ orientation=0.0,
+ )
+
+
+def _frame(
+ ball: Ball,
+ friendly_robots: dict | None = None,
+ enemy_robots: dict | None = None,
+ my_team_is_yellow: bool = True,
+ my_team_is_right: bool = False,
+ ts: float = 10.0,
+) -> GameFrame:
+ return GameFrame(
+ ts=ts,
+ my_team_is_yellow=my_team_is_yellow,
+ my_team_is_right=my_team_is_right,
+ friendly_robots=friendly_robots or {},
+ enemy_robots=enemy_robots or {},
+ ball=ball,
+ referee=None,
+ )
+
+
+def _state_machine() -> GameStateMachine:
+ sm = GameStateMachine(
+ half_duration_seconds=300.0,
+ kickoff_team="yellow",
+ n_robots_yellow=3,
+ n_robots_blue=3,
+ )
+ sm.seed_clock(0.0)
+ sm.set_command(RefereeCommand.NORMAL_START, 0.0)
+ return sm
+
+
+# ---------------------------------------------------------------------------
+# GoalRule
+# ---------------------------------------------------------------------------
+
+
+class TestGoalRule:
+ def test_right_goal_blue_scores_when_yellow_is_right(self):
+ # Yellow defends right goal → ball in right goal → blue scored → yellow kicks off.
+ rule = GoalRule(cooldown_seconds=1.0)
+ frame = _frame(ball=_ball(5.0, 0.0), my_team_is_yellow=True, my_team_is_right=True)
+ violation = rule.check(frame, GEO, RefereeCommand.NORMAL_START)
+ assert violation is not None
+ assert violation.rule_name == "goal"
+ assert violation.status_message == "Goal by Blue"
+ assert violation.next_command == RefereeCommand.PREPARE_KICKOFF_YELLOW
+
+ def test_left_goal_yellow_scores_when_yellow_is_right(self):
+ # Blue defends left goal → ball in left goal → yellow scored → blue kicks off.
+ rule = GoalRule(cooldown_seconds=1.0)
+ frame = _frame(ball=_ball(-5.0, 0.0), my_team_is_yellow=True, my_team_is_right=True)
+ violation = rule.check(frame, GEO, RefereeCommand.NORMAL_START)
+ assert violation is not None
+ assert violation.status_message == "Goal by Yellow"
+ assert violation.next_command == RefereeCommand.PREPARE_KICKOFF_BLUE
+
+ def test_right_goal_yellow_scores_when_yellow_is_left(self):
+ # Blue defends right goal → ball in right goal → yellow scored → blue kicks off.
+ rule = GoalRule(cooldown_seconds=1.0)
+ frame = _frame(ball=_ball(5.0, 0.0), my_team_is_yellow=True, my_team_is_right=False)
+ violation = rule.check(frame, GEO, RefereeCommand.NORMAL_START)
+ assert violation is not None
+ assert violation.status_message == "Goal by Yellow"
+ assert violation.next_command == RefereeCommand.PREPARE_KICKOFF_BLUE
+
+ def test_no_goal_wide_shot(self):
+ rule = GoalRule()
+ frame = _frame(ball=_ball(5.0, 1.0)) # y=1.0 > half_goal_width=0.5
+ assert rule.check(frame, GEO, RefereeCommand.NORMAL_START) is None
+
+ def test_goal_respects_cooldown(self):
+ rule = GoalRule(cooldown_seconds=2.0)
+ frame1 = _frame(ball=_ball(5.0, 0.0), my_team_is_right=True, ts=10.0)
+ v1 = rule.check(frame1, GEO, RefereeCommand.NORMAL_START)
+ assert v1 is not None
+
+ # Second detection within cooldown window — must be suppressed.
+ frame2 = _frame(ball=_ball(5.0, 0.0), my_team_is_right=True, ts=10.5)
+ v2 = rule.check(frame2, GEO, RefereeCommand.NORMAL_START)
+ assert v2 is None
+
+ # After cooldown expires — should fire again.
+ frame3 = _frame(ball=_ball(5.0, 0.0), my_team_is_right=True, ts=13.0)
+ v3 = rule.check(frame3, GEO, RefereeCommand.NORMAL_START)
+ assert v3 is not None
+
+ def test_no_detection_during_stop(self):
+ rule = GoalRule()
+ frame = _frame(ball=_ball(5.0, 0.0))
+ assert rule.check(frame, GEO, RefereeCommand.STOP) is None
+
+
+# ---------------------------------------------------------------------------
+# OutOfBoundsRule
+# ---------------------------------------------------------------------------
+
+
+class TestOutOfBoundsRule:
+ def test_ball_out_top(self):
+ rule = OutOfBoundsRule()
+ frame = _frame(ball=_ball(0.0, 3.5), my_team_is_yellow=True)
+ violation = rule.check(frame, GEO, RefereeCommand.NORMAL_START)
+ assert violation is not None
+ assert violation.rule_name == "out_of_bounds"
+
+ def test_ball_out_right_side(self):
+ rule = OutOfBoundsRule()
+ frame = _frame(ball=_ball(5.0, 1.0)) # wide — not in goal (y=1.0 > 0.5)
+ violation = rule.check(frame, GEO, RefereeCommand.NORMAL_START)
+ assert violation is not None
+
+ def test_ball_in_field_no_violation(self):
+ rule = OutOfBoundsRule()
+ frame = _frame(ball=_ball(0.0, 0.0))
+ assert rule.check(frame, GEO, RefereeCommand.NORMAL_START) is None
+
+ def test_ball_in_goal_no_out_of_bounds(self):
+ rule = OutOfBoundsRule()
+ frame = _frame(ball=_ball(5.0, 0.0)) # in right goal
+ # GoalRule handles this; OutOfBoundsRule must not also fire.
+ assert rule.check(frame, GEO, RefereeCommand.NORMAL_START) is None
+
+ def test_free_kick_assigned_to_non_touching_team_friendly_yellow(self):
+ """Friendly (yellow) last touched → enemy (blue) gets free kick."""
+ rule = OutOfBoundsRule()
+ friendly = {0: _robot(0, 4.4, 2.9, is_friendly=True, has_ball=True)}
+ frame_before = _frame(ball=_ball(4.4, 2.9), friendly_robots=friendly, my_team_is_yellow=True, ts=9.9)
+ rule.check(frame_before, GEO, RefereeCommand.NORMAL_START)
+
+ frame_out = _frame(ball=_ball(0.0, 3.5), my_team_is_yellow=True, ts=10.0)
+ violation = rule.check(frame_out, GEO, RefereeCommand.NORMAL_START)
+ assert violation is not None
+ assert violation.next_command == RefereeCommand.DIRECT_FREE_BLUE
+
+ def test_designated_position_is_infield(self):
+ rule = OutOfBoundsRule()
+ frame = _frame(ball=_ball(0.0, 3.5))
+ violation = rule.check(frame, GEO, RefereeCommand.NORMAL_START)
+ assert violation is not None
+ px, py = violation.designated_position
+ assert abs(py) < GEO.half_width # placed infield
+
+
+# ---------------------------------------------------------------------------
+# DefenseAreaRule
+# ---------------------------------------------------------------------------
+
+
+class TestDefenseAreaRule:
+ def _frame_with_attacker_in_defense(self, my_team_is_right: bool = False) -> GameFrame:
+ # Enemy robot inside my (left) defense area.
+ enemy = {0: _robot(0, -4.3, 0.5, is_friendly=False)}
+ return _frame(
+ ball=_ball(0, 0),
+ enemy_robots=enemy,
+ my_team_is_right=my_team_is_right,
+ )
+
+ def test_fires_during_normal_start(self):
+ rule = DefenseAreaRule()
+ frame = self._frame_with_attacker_in_defense(my_team_is_right=False)
+ v = rule.check(frame, GEO, RefereeCommand.NORMAL_START)
+ assert v is not None
+ assert v.rule_name == "defense_area"
+
+ def test_does_not_fire_during_stop(self):
+ rule = DefenseAreaRule()
+ frame = self._frame_with_attacker_in_defense()
+ assert rule.check(frame, GEO, RefereeCommand.STOP) is None
+
+ def test_does_not_fire_during_force_start_no_actually_fires(self):
+ rule = DefenseAreaRule()
+ frame = self._frame_with_attacker_in_defense(my_team_is_right=False)
+ v = rule.check(frame, GEO, RefereeCommand.FORCE_START)
+ assert v is not None
+
+ def test_too_many_defenders(self):
+ rule = DefenseAreaRule(max_defenders=1)
+ # Two friendly robots in own (left) defense area.
+ friendly = {
+ 0: _robot(0, -4.3, 0.0, is_friendly=True),
+ 1: _robot(1, -4.3, 0.5, is_friendly=True),
+ }
+ frame = _frame(ball=_ball(0, 0), friendly_robots=friendly, my_team_is_right=False, my_team_is_yellow=True)
+ v = rule.check(frame, GEO, RefereeCommand.NORMAL_START)
+ assert v is not None
+ assert v.next_command == RefereeCommand.DIRECT_FREE_BLUE
+
+
+# ---------------------------------------------------------------------------
+# KeepOutRule
+# ---------------------------------------------------------------------------
+
+
+class TestKeepOutRule:
+ def test_no_trigger_before_persistence_threshold(self):
+ rule = KeepOutRule(radius_meters=0.5, violation_persistence_frames=5)
+ friendly = {0: _robot(0, 0.2, 0.0, is_friendly=True)}
+ # Enemy is kicking → check friendly.
+ for _ in range(4):
+ frame = _frame(ball=_ball(0.0, 0.0), friendly_robots=friendly)
+ v = rule.check(frame, GEO, RefereeCommand.DIRECT_FREE_BLUE)
+ assert v is None
+
+ def test_triggers_after_persistence_threshold(self):
+ rule = KeepOutRule(radius_meters=0.5, violation_persistence_frames=5)
+ friendly = {0: _robot(0, 0.2, 0.0, is_friendly=True)}
+ v = None
+ for _ in range(5):
+ frame = _frame(ball=_ball(0.0, 0.0), friendly_robots=friendly)
+ v = rule.check(frame, GEO, RefereeCommand.DIRECT_FREE_BLUE)
+ assert v is not None
+ assert v.rule_name == "keep_out"
+
+ def test_resets_on_non_violation_frame(self):
+ rule = KeepOutRule(radius_meters=0.5, violation_persistence_frames=5)
+ friendly = {0: _robot(0, 0.2, 0.0, is_friendly=True)}
+ for _ in range(4):
+ frame = _frame(ball=_ball(0.0, 0.0), friendly_robots=friendly)
+ rule.check(frame, GEO, RefereeCommand.DIRECT_FREE_BLUE)
+
+ # Robot moves away — count resets.
+ far_frame = _frame(
+ ball=_ball(0.0, 0.0),
+ friendly_robots={0: _robot(0, 2.0, 0.0, is_friendly=True)},
+ )
+ rule.check(far_frame, GEO, RefereeCommand.DIRECT_FREE_BLUE)
+
+ # Needs another full persistence run to trigger.
+ v = None
+ for _ in range(5):
+ frame = _frame(ball=_ball(0.0, 0.0), friendly_robots=friendly)
+ v = rule.check(frame, GEO, RefereeCommand.DIRECT_FREE_BLUE)
+ assert v is not None
+
+ def test_inactive_during_normal_start(self):
+ rule = KeepOutRule(radius_meters=0.5, violation_persistence_frames=1)
+ friendly = {0: _robot(0, 0.1, 0.0, is_friendly=True)}
+ frame = _frame(ball=_ball(0.0, 0.0), friendly_robots=friendly)
+ assert rule.check(frame, GEO, RefereeCommand.NORMAL_START) is None
+
+
+# ---------------------------------------------------------------------------
+# GameStateMachine
+# ---------------------------------------------------------------------------
+
+
+class TestGameStateMachine:
+ def test_goal_increments_yellow_score(self):
+ from utama_core.custom_referee.rules.base_rule import RuleViolation
+
+ sm = _state_machine()
+ violation = RuleViolation(
+ rule_name="goal",
+ suggested_command=RefereeCommand.STOP,
+ next_command=RefereeCommand.PREPARE_KICKOFF_BLUE,
+ status_message="Goal by Yellow",
+ )
+ data = sm.step(current_time=10.0, violation=violation)
+ assert sm.yellow_team.score == 1
+ assert sm.blue_team.score == 0
+ assert data.referee_command == RefereeCommand.STOP
+ assert data.next_command == RefereeCommand.BALL_PLACEMENT_BLUE
+
+ def test_goal_increments_blue_score(self):
+ from utama_core.custom_referee.rules.base_rule import RuleViolation
+
+ sm = _state_machine()
+ violation = RuleViolation(
+ rule_name="goal",
+ suggested_command=RefereeCommand.STOP,
+ next_command=RefereeCommand.PREPARE_KICKOFF_YELLOW,
+ status_message="Goal by Blue",
+ )
+ sm.step(current_time=10.0, violation=violation)
+ assert sm.blue_team.score == 1
+ assert sm.yellow_team.score == 0
+
+ def test_transition_cooldown_suppresses_duplicate(self):
+ from utama_core.custom_referee.rules.base_rule import RuleViolation
+
+ sm = _state_machine()
+ violation = RuleViolation(
+ rule_name="goal",
+ suggested_command=RefereeCommand.STOP,
+ next_command=RefereeCommand.PREPARE_KICKOFF_BLUE,
+ status_message="Goal",
+ )
+ sm.step(current_time=10.0, violation=violation)
+ assert sm.yellow_team.score == 1
+
+ # Second goal within cooldown window — must be suppressed.
+ sm.step(current_time=10.1, violation=violation)
+ assert sm.yellow_team.score == 1 # still 1
+
+ def test_goal_sets_designated_position_to_centre(self):
+ from utama_core.custom_referee.rules.base_rule import RuleViolation
+
+ sm = _state_machine()
+ violation = RuleViolation(
+ rule_name="goal",
+ suggested_command=RefereeCommand.STOP,
+ next_command=RefereeCommand.PREPARE_KICKOFF_BLUE,
+ status_message="Goal by Yellow",
+ )
+ data = sm.step(current_time=10.0, violation=violation)
+ assert data.designated_position == (0.0, 0.0)
+
+ def test_goal_status_message_is_propagated_into_referee_data(self):
+ from utama_core.custom_referee.rules.base_rule import RuleViolation
+
+ sm = _state_machine()
+ violation = RuleViolation(
+ rule_name="goal",
+ suggested_command=RefereeCommand.STOP,
+ next_command=RefereeCommand.PREPARE_KICKOFF_BLUE,
+ status_message="Goal by Yellow",
+ )
+ data = sm.step(current_time=10.0, violation=violation)
+ assert data.status_message == "Goal by Yellow"
+
+ def test_manual_command_clears_status_message(self):
+ from utama_core.custom_referee.rules.base_rule import RuleViolation
+
+ sm = _state_machine()
+ violation = RuleViolation(
+ rule_name="goal",
+ suggested_command=RefereeCommand.STOP,
+ next_command=RefereeCommand.PREPARE_KICKOFF_BLUE,
+ status_message="Goal by Yellow",
+ )
+ sm.step(current_time=10.0, violation=violation)
+
+ sm.set_command(RefereeCommand.NORMAL_START, timestamp=11.0)
+ data = sm.step(current_time=11.0, violation=None)
+ assert data.status_message is None
+
+ def test_manual_set_command(self):
+ sm = _state_machine()
+ sm.set_command(RefereeCommand.NORMAL_START, timestamp=5.0)
+ assert sm.command == RefereeCommand.NORMAL_START
+
+ def test_manual_ball_placement_auto_advances_from_stop_and_then_to_normal_start(self):
+ sm = _state_machine()
+ sm.set_command(RefereeCommand.BALL_PLACEMENT_YELLOW, timestamp=1.0)
+ sm.ball_placement_target = (0.0, 0.0)
+
+ clear_frame = _frame(ball=_ball(2.0, 0.0), ts=10.0)
+ data = sm.step(current_time=10.0, violation=None, game_frame=clear_frame)
+ assert data.referee_command == RefereeCommand.BALL_PLACEMENT_YELLOW
+ assert data.next_command == RefereeCommand.NORMAL_START
+
+ placed_frame = _frame(ball=_ball(0.0, 0.0), ts=20.0)
+ sm.step(current_time=20.0, violation=None, game_frame=placed_frame)
+ data = sm.step(current_time=23.0, violation=None, game_frame=placed_frame)
+ assert data.referee_command == RefereeCommand.NORMAL_START
+ assert data.next_command is None
+
+ def test_manual_penalty_auto_advances_from_stop_and_then_to_normal_start(self):
+ sm = _state_machine()
+ sm.set_command(RefereeCommand.PREPARE_PENALTY_YELLOW, timestamp=1.0)
+
+ clear_frame = _frame(ball=_ball(0.0, 0.0), ts=10.0)
+ data = sm.step(current_time=10.0, violation=None, game_frame=clear_frame)
+ assert data.referee_command == RefereeCommand.PREPARE_PENALTY_YELLOW
+ assert data.next_command == RefereeCommand.NORMAL_START
+
+ ready_attackers = {0: _robot(0, 2.25, 0.0, is_friendly=True)}
+ ready_frame = _frame(ball=_ball(0.0, 0.0), friendly_robots=ready_attackers, ts=14.0)
+ sm.step(current_time=14.0, violation=None, game_frame=ready_frame)
+ data = sm.step(current_time=17.0, violation=None, game_frame=ready_frame)
+ assert data.referee_command == RefereeCommand.NORMAL_START
+ assert data.next_command is None
+
+
+# ---------------------------------------------------------------------------
+# CustomReferee integration
+# ---------------------------------------------------------------------------
+
+
+class TestCustomReferee:
+ def test_returns_valid_referee_data(self):
+ referee = CustomReferee.from_profile_name("simulation")
+ frame = _frame(ball=_ball(0.0, 0.0))
+ data = referee.step(frame, current_time=10.0)
+ assert isinstance(data, RefereeData)
+ assert data.source_identifier == "custom_referee"
+
+ def test_simulation_goal_auto_advances_to_ball_placement_and_scores(self):
+ referee = CustomReferee.from_profile_name("simulation")
+ referee.set_command(RefereeCommand.NORMAL_START, timestamp=0.0)
+
+ # Yellow is on the RIGHT — ball in right goal means yellow conceded, blue scored.
+ frame = _frame(ball=_ball(5.0, 0.0), my_team_is_yellow=True, my_team_is_right=True, ts=10.0)
+ data = referee.step(frame, current_time=10.0)
+
assert data.referee_command == RefereeCommand.BALL_PLACEMENT_YELLOW
- assert data.next_command == RefereeCommand.NORMAL_START
-
- placed_frame = _frame(ball=_ball(0.0, 0.0), ts=20.0)
- sm.step(current_time=20.0, violation=None, game_frame=placed_frame)
- data = sm.step(current_time=23.0, violation=None, game_frame=placed_frame)
- assert data.referee_command == RefereeCommand.NORMAL_START
- assert data.next_command is None
-
- def test_manual_penalty_auto_advances_from_stop_and_then_to_normal_start(self):
- sm = _state_machine()
- sm.set_command(RefereeCommand.PREPARE_PENALTY_YELLOW, timestamp=1.0)
-
- clear_frame = _frame(ball=_ball(0.0, 0.0), ts=10.0)
- data = sm.step(current_time=10.0, violation=None, game_frame=clear_frame)
- assert data.referee_command == RefereeCommand.PREPARE_PENALTY_YELLOW
- assert data.next_command == RefereeCommand.NORMAL_START
-
- ready_attackers = {0: _robot(0, 2.25, 0.0, is_friendly=True)}
- ready_frame = _frame(ball=_ball(0.0, 0.0), friendly_robots=ready_attackers, ts=14.0)
- sm.step(current_time=14.0, violation=None, game_frame=ready_frame)
- data = sm.step(current_time=17.0, violation=None, game_frame=ready_frame)
- assert data.referee_command == RefereeCommand.NORMAL_START
- assert data.next_command is None
-
-
-# ---------------------------------------------------------------------------
-# CustomReferee integration
-# ---------------------------------------------------------------------------
-
-
-class TestCustomReferee:
- def test_returns_valid_referee_data(self):
- referee = CustomReferee.from_profile_name("simulation")
- frame = _frame(ball=_ball(0.0, 0.0))
- data = referee.step(frame, current_time=10.0)
- assert isinstance(data, RefereeData)
- assert data.source_identifier == "custom_referee"
-
- def test_simulation_goal_auto_advances_to_prepare_kickoff_and_scores(self):
- referee = CustomReferee.from_profile_name("simulation")
- referee.set_command(RefereeCommand.NORMAL_START, timestamp=0.0)
-
- # Yellow is on the RIGHT — ball in right goal means yellow conceded, blue scored.
- frame = _frame(ball=_ball(5.0, 0.0), my_team_is_yellow=True, my_team_is_right=True, ts=10.0)
- data = referee.step(frame, current_time=10.0)
-
- assert data.referee_command == RefereeCommand.PREPARE_KICKOFF_YELLOW
assert data.blue_team.score == 1
assert data.yellow_team.score == 0
- assert data.next_command == RefereeCommand.NORMAL_START
-
- def test_human_profile_no_oob(self):
- """Human profile disables out-of-bounds — ball outside must not trigger."""
- referee = CustomReferee.from_profile_name("human")
- referee.set_command(RefereeCommand.NORMAL_START, timestamp=0.0)
- frame = _frame(ball=_ball(0.0, 4.0), ts=10.0) # ball outside field width
- data = referee.step(frame, current_time=10.0)
- assert data.referee_command == RefereeCommand.NORMAL_START
-
- def test_simulation_oob_exposes_status_message(self):
- referee = CustomReferee.from_profile_name("simulation")
- referee.set_command(RefereeCommand.NORMAL_START, timestamp=0.0)
- frame = _frame(ball=_ball(0.0, 4.0), ts=10.0)
- data = referee.step(frame, current_time=10.0)
- assert data.status_message == "Ball out of bounds"
-
- def test_human_stays_in_stop_after_goal_until_operator_advances(self):
- """Human mode keeps the game in STOP after a goal for operator control."""
- referee = CustomReferee.from_profile_name("human")
- referee.set_command(RefereeCommand.NORMAL_START, timestamp=0.0)
-
- # Score a goal (yellow on right, blue scores).
- goal_frame = _frame(ball=_ball(5.0, 0.0), my_team_is_yellow=True, my_team_is_right=True, ts=10.0)
- data = referee.step(goal_frame, current_time=10.0)
- assert data.referee_command == RefereeCommand.STOP
+ assert data.next_command == RefereeCommand.PREPARE_KICKOFF_YELLOW
+
+ def test_human_profile_no_oob(self):
+ """Human profile disables out-of-bounds — ball outside must not trigger."""
+ referee = CustomReferee.from_profile_name("human")
+ referee.set_command(RefereeCommand.NORMAL_START, timestamp=0.0)
+ frame = _frame(ball=_ball(0.0, 4.0), ts=10.0) # ball outside field width
+ data = referee.step(frame, current_time=10.0)
+ assert data.referee_command == RefereeCommand.NORMAL_START
+
+ def test_simulation_oob_exposes_status_message(self):
+ referee = CustomReferee.from_profile_name("simulation")
+ referee.set_command(RefereeCommand.NORMAL_START, timestamp=0.0)
+ frame = _frame(ball=_ball(0.0, 4.0), ts=10.0)
+ data = referee.step(frame, current_time=10.0)
+ assert data.status_message == "Ball out of bounds"
+
+ def test_human_stays_in_stop_after_goal_until_operator_advances(self):
+ """Human mode keeps the game in STOP after a goal for operator control."""
+ referee = CustomReferee.from_profile_name("human")
+ referee.set_command(RefereeCommand.NORMAL_START, timestamp=0.0)
+
+ # Score a goal (yellow on right, blue scores).
+ goal_frame = _frame(ball=_ball(5.0, 0.0), my_team_is_yellow=True, my_team_is_right=True, ts=10.0)
+ data = referee.step(goal_frame, current_time=10.0)
+ assert data.referee_command == RefereeCommand.STOP
+ assert data.next_command == RefereeCommand.BALL_PLACEMENT_YELLOW
+
+ # Still in STOP later — operator must choose the next command.
+ still_stop = referee.step(_frame(ball=_ball(0.0, 0.0), ts=70.0), current_time=70.0)
+ assert still_stop.referee_command == RefereeCommand.STOP
+
+ def test_simulation_stays_in_ball_placement_after_goal_until_ball_is_placed(self):
+ """Simulation mode auto-advances into BALL_PLACEMENT and waits for the ball at centre."""
+ referee = CustomReferee.from_profile_name("simulation")
+ referee.set_command(RefereeCommand.NORMAL_START, timestamp=0.0)
+
+ goal_frame = _frame(ball=_ball(5.0, 0.0), my_team_is_yellow=True, my_team_is_right=True, ts=10.0)
+ referee.step(goal_frame, current_time=10.0)
+
+ data = referee.step(_frame(ball=_ball(1.0, 0.0), ts=70.0), current_time=70.0)
+ assert data.referee_command == RefereeCommand.BALL_PLACEMENT_YELLOW
assert data.next_command == RefereeCommand.PREPARE_KICKOFF_YELLOW
- # Still in STOP later — operator must choose the next command.
- still_stop = referee.step(_frame(ball=_ball(0.0, 0.0), ts=70.0), current_time=70.0)
- assert still_stop.referee_command == RefereeCommand.STOP
-
- def test_simulation_stays_in_prepare_kickoff_after_goal_without_ready_kicker(self):
- """Simulation mode auto-advances into PREPARE_KICKOFF and waits there until ready."""
- referee = CustomReferee.from_profile_name("simulation")
- referee.set_command(RefereeCommand.NORMAL_START, timestamp=0.0)
-
- goal_frame = _frame(ball=_ball(5.0, 0.0), my_team_is_yellow=True, my_team_is_right=True, ts=10.0)
- referee.step(goal_frame, current_time=10.0)
-
- # With no kicker in the centre circle, the state remains in PREPARE_KICKOFF.
- data = referee.step(_frame(ball=_ball(0.0, 0.0), ts=70.0), current_time=70.0)
- assert data.referee_command == RefereeCommand.PREPARE_KICKOFF_YELLOW
-
- def test_simulation_oob_auto_advances_to_direct_free_and_then_normal_start(self):
- referee = CustomReferee.from_profile_name("simulation")
- referee.set_command(RefereeCommand.NORMAL_START, timestamp=0.0)
-
- touch_frame = _frame(
- ball=_ball(4.4, 2.9),
- friendly_robots={0: _robot(0, 4.4, 2.9, is_friendly=True, has_ball=True)},
- my_team_is_yellow=True,
- ts=9.9,
- )
- referee.step(touch_frame, current_time=9.9)
+ def test_simulation_oob_auto_advances_to_ball_placement_then_direct_free(self):
+ referee = CustomReferee.from_profile_name("simulation")
+ referee.set_command(RefereeCommand.NORMAL_START, timestamp=0.0)
+
+ touch_frame = _frame(
+ ball=_ball(4.4, 2.9),
+ friendly_robots={0: _robot(0, 4.4, 2.9, is_friendly=True, has_ball=True)},
+ my_team_is_yellow=True,
+ ts=9.9,
+ )
+ referee.step(touch_frame, current_time=9.9)
+
+ out_frame = _frame(ball=_ball(0.0, 3.5), my_team_is_yellow=True, ts=10.0)
+ data = referee.step(out_frame, current_time=10.0)
+ assert data.referee_command == RefereeCommand.BALL_PLACEMENT_BLUE
+ assert data.next_command == RefereeCommand.DIRECT_FREE_BLUE
- out_frame = _frame(ball=_ball(0.0, 3.5), my_team_is_yellow=True, ts=10.0)
- data = referee.step(out_frame, current_time=10.0)
+ ready_frame = _frame(
+ ball=_ball(0.0, 2.9),
+ friendly_robots={0: _robot(0, 1.0, 0.0, is_friendly=True)},
+ enemy_robots={0: _robot(0, 0.1, 0.0, is_friendly=False)},
+ my_team_is_yellow=True,
+ ts=20.0,
+ )
+ referee.step(ready_frame, current_time=20.0)
+ data = referee.step(ready_frame, current_time=23.0)
assert data.referee_command == RefereeCommand.DIRECT_FREE_BLUE
assert data.next_command == RefereeCommand.NORMAL_START
-
- ready_frame = _frame(
- ball=_ball(0.0, 0.0),
- friendly_robots={0: _robot(0, 1.0, 0.0, is_friendly=True)},
- enemy_robots={0: _robot(0, 0.1, 0.0, is_friendly=False)},
- my_team_is_yellow=True,
- ts=20.0,
- )
- referee.step(ready_frame, current_time=20.0)
- data = referee.step(ready_frame, current_time=23.0)
- assert data.referee_command == RefereeCommand.NORMAL_START
-
- def test_human_manual_direct_free_stays_in_stop_until_operator_advances(self):
- referee = CustomReferee.from_profile_name("human")
- referee.set_command(RefereeCommand.NORMAL_START, timestamp=0.0)
- referee.set_command(RefereeCommand.DIRECT_FREE_BLUE, timestamp=1.0)
-
- data = referee.step(_frame(ball=_ball(0.0, 0.0), my_team_is_yellow=True, ts=10.0), current_time=10.0)
- assert data.referee_command == RefereeCommand.STOP
- assert data.next_command == RefereeCommand.DIRECT_FREE_BLUE
-
- still_stop = referee.step(_frame(ball=_ball(0.0, 0.0), ts=40.0), current_time=40.0)
- assert still_stop.referee_command == RefereeCommand.STOP
-
- def test_human_manual_penalty_stays_in_stop_until_operator_advances(self):
- referee = CustomReferee.from_profile_name("human")
- referee.set_command(RefereeCommand.NORMAL_START, timestamp=0.0)
- referee.set_command(RefereeCommand.PREPARE_PENALTY_YELLOW, timestamp=1.0)
-
- data = referee.step(_frame(ball=_ball(0.0, 0.0), ts=10.0), current_time=10.0)
- assert data.referee_command == RefereeCommand.STOP
- assert data.next_command == RefereeCommand.PREPARE_PENALTY_YELLOW
-
-
-# ---------------------------------------------------------------------------
-# Profile loader
-# ---------------------------------------------------------------------------
-
-
-class TestProfileLoader:
- def test_simulation_loads(self):
- profile = load_profile("simulation")
- assert profile.profile_name == "simulation"
- assert profile.rules.goal_detection.enabled is True
- assert profile.rules.keep_out.radius_meters == 0.5
-
- def test_human_loads(self):
- profile = load_profile("human")
- assert profile.profile_name == "human"
- assert profile.rules.out_of_bounds.enabled is False
- assert profile.game.force_start_after_goal is False
- assert profile.game.auto_advance.stop_to_next_command is False
- assert profile.game.auto_advance.prepare_penalty_to_normal is False
-
- def test_legacy_stop_to_prepare_kickoff_key_is_still_loaded(self, tmp_path):
- profile_path = tmp_path / "legacy_profile.yaml"
- profile_path.write_text(
- """
-profile_name: "legacy"
-geometry: {}
-rules: {}
-game:
- auto_advance:
- stop_to_prepare_kickoff: false
-""".strip()
- )
-
- profile = load_profile(str(profile_path))
- assert profile.game.auto_advance.stop_to_next_command is False
-
- def test_unknown_profile_raises(self):
- with pytest.raises(FileNotFoundError):
- load_profile("nonexistent_profile")
+
+ def test_human_manual_direct_free_stays_in_stop_until_operator_advances(self):
+ referee = CustomReferee.from_profile_name("human")
+ referee.set_command(RefereeCommand.NORMAL_START, timestamp=0.0)
+ referee.set_command(RefereeCommand.DIRECT_FREE_BLUE, timestamp=1.0)
+
+ data = referee.step(_frame(ball=_ball(0.0, 0.0), my_team_is_yellow=True, ts=10.0), current_time=10.0)
+ assert data.referee_command == RefereeCommand.STOP
+ assert data.next_command == RefereeCommand.DIRECT_FREE_BLUE
+
+ still_stop = referee.step(_frame(ball=_ball(0.0, 0.0), ts=40.0), current_time=40.0)
+ assert still_stop.referee_command == RefereeCommand.STOP
+
+ def test_human_manual_penalty_stays_in_stop_until_operator_advances(self):
+ referee = CustomReferee.from_profile_name("human")
+ referee.set_command(RefereeCommand.NORMAL_START, timestamp=0.0)
+ referee.set_command(RefereeCommand.PREPARE_PENALTY_YELLOW, timestamp=1.0)
+
+ data = referee.step(_frame(ball=_ball(0.0, 0.0), ts=10.0), current_time=10.0)
+ assert data.referee_command == RefereeCommand.STOP
+ assert data.next_command == RefereeCommand.PREPARE_PENALTY_YELLOW
+
+
+# ---------------------------------------------------------------------------
+# Profile loader
+# ---------------------------------------------------------------------------
+
+
+class TestProfileLoader:
+ def test_simulation_loads(self):
+ profile = load_profile("simulation")
+ assert profile.profile_name == "simulation"
+ assert profile.rules.goal_detection.enabled is True
+ assert profile.rules.keep_out.radius_meters == 0.5
+
+ def test_human_loads(self):
+ profile = load_profile("human")
+ assert profile.profile_name == "human"
+ assert profile.rules.out_of_bounds.enabled is False
+ assert profile.game.force_start_after_goal is False
+ assert profile.game.auto_advance.stop_to_next_command is False
+ assert profile.game.auto_advance.prepare_penalty_to_normal is False
+
+ def test_legacy_stop_to_prepare_kickoff_key_is_still_loaded(self, tmp_path):
+ profile_path = tmp_path / "legacy_profile.yaml"
+ profile_path.write_text(
+ """
+profile_name: "legacy"
+geometry: {}
+rules: {}
+game:
+ auto_advance:
+ stop_to_prepare_kickoff: false
+""".strip()
+ )
+
+ profile = load_profile(str(profile_path))
+ assert profile.game.auto_advance.stop_to_next_command is False
+
+ def test_unknown_profile_raises(self):
+ with pytest.raises(FileNotFoundError):
+ load_profile("nonexistent_profile")
diff --git a/utama_core/tests/referee/test_referee_unit.py b/utama_core/tests/referee/test_referee_unit.py
index ce99eb69..dc07184c 100644
--- a/utama_core/tests/referee/test_referee_unit.py
+++ b/utama_core/tests/referee/test_referee_unit.py
@@ -450,16 +450,27 @@ def fake_move(game, motion_controller, robot_id, target_coords, target_oren, dri
assert cmd_map[1] is not None
def test_robot_with_ball_moves_to_designated_position(self, monkeypatch):
+ import math
+
from utama_core.strategy.referee import actions as referee_actions
- captured = []
+ move_captured = []
+ turn_captured = []
def fake_move(game, motion_controller, robot_id, target_coords, target_oren, dribbling=False):
- captured.append((robot_id, target_coords, dribbling))
+ move_captured.append((robot_id, target_coords, dribbling))
return ("move", robot_id)
+ def fake_turn_on_spot(game, motion_controller, robot_id, target_oren, dribbling=False):
+ turn_captured.append((robot_id, dribbling))
+ return ("turn", robot_id)
+
monkeypatch.setattr(referee_actions, "move", fake_move)
+ monkeypatch.setattr(referee_actions, "turn_on_spot", fake_turn_on_spot)
+ target = (1.5, -0.5)
+ # Orient the robot to face the target so face_error < threshold → move branch.
+ facing = math.atan2(target[1], target[0])
robots = {
0: Robot(
id=0,
@@ -468,13 +479,13 @@ def fake_move(game, motion_controller, robot_id, target_coords, target_oren, dri
p=Vector2D(0.0, 0.0),
v=Vector2D(0.0, 0.0),
a=Vector2D(0.0, 0.0),
- orientation=0.0,
+ orientation=facing,
)
}
referee = _make_referee_data(
command=RefereeCommand.BALL_PLACEMENT_YELLOW,
)
- referee.designated_position = (1.5, -0.5)
+ referee.designated_position = target
frame = GameFrame(
ts=0.0,
my_team_is_yellow=True,
@@ -501,9 +512,11 @@ def fake_move(game, motion_controller, robot_id, target_coords, target_oren, dri
status = node.update()
assert status == py_trees.common.Status.RUNNING
- assert captured[0][0] == 0
- assert captured[0][1] == Vector2D(1.5, -0.5)
- assert captured[0][2] is True
+ # Robot already faces target → should call move (not turn_on_spot).
+ assert len(move_captured) >= 1
+ assert move_captured[0][0] == 0
+ assert move_captured[0][1] == Vector2D(1.5, -0.5)
+ assert move_captured[0][2] is True
def test_non_placing_teammate_clears_from_ball(self, monkeypatch):
from utama_core.strategy.referee import actions as referee_actions
@@ -1253,8 +1266,12 @@ def fake_move(game, motion_controller, robot_id, target_coords, target_oren, dri
kicker_entries = [entry for entry in captured if entry[0] == 1]
assert len(kicker_entries) == 1
- assert kicker_entries[0][1].x == pytest.approx(1.0)
- assert kicker_entries[0][1].y == pytest.approx(0.0)
+ # Target is the approach point behind the ball, not the ball itself —
+ # verify it is closer to the ball than robot 1's starting position.
+ ball_pos = Vector2D(1.0, 0.0)
+ robot1_start = Vector2D(1.2, 0.0)
+ target = kicker_entries[0][1]
+ assert target.distance_to(ball_pos) < robot1_start.distance_to(ball_pos)
def test_kicker_moves_toward_ball(self, monkeypatch):
from utama_core.strategy.referee import actions as referee_actions
@@ -1294,8 +1311,12 @@ def fake_move(game, motion_controller, robot_id, target_coords, target_oren, dri
assert len(captured) == 1
assert captured[0][0] == 0
- assert captured[0][1].x == pytest.approx(2.0)
- assert captured[0][1].y == pytest.approx(1.0)
+ # Target is the approach point behind the ball, not the ball itself —
+ # verify it is closer to the ball than the robot's starting position.
+ ball_pos = Vector2D(2.0, 1.0)
+ robot_start = Vector2D(0.5, 0.0)
+ target = captured[0][1]
+ assert target.distance_to(ball_pos) < robot_start.distance_to(ball_pos)
def test_non_kicker_robots_get_stop_command(self, monkeypatch):
from utama_core.strategy.referee import actions as referee_actions
diff --git a/utama_core/tests/refiners/robot_info_test.py b/utama_core/tests/refiners/robot_info_test.py
new file mode 100644
index 00000000..27271bec
--- /dev/null
+++ b/utama_core/tests/refiners/robot_info_test.py
@@ -0,0 +1,170 @@
+import pytest
+
+from utama_core.data_processing.refiners.robot_info import (
+ _BALL_CAPTURE_DIST,
+ RobotInfoRefiner,
+)
+from utama_core.entities.data.command import RobotResponse
+from utama_core.entities.data.vector import Vector2D, Vector3D
+from utama_core.entities.game.ball import Ball
+from utama_core.entities.game.game_frame import GameFrame
+from utama_core.entities.game.robot import Robot
+
+
+def _make_robot(robot_id: int, x: float, y: float, has_ball: bool = False) -> Robot:
+ return Robot(
+ id=robot_id,
+ is_friendly=True,
+ has_ball=has_ball,
+ p=Vector2D(x, y),
+ v=None,
+ a=None,
+ orientation=0,
+ )
+
+
+def _make_frame(robots: dict, ball_pos=None) -> GameFrame:
+ ball = Ball(Vector3D(*ball_pos, 0), Vector3D(0, 0, 0), None) if ball_pos else None
+ return GameFrame(
+ ts=0.0,
+ my_team_is_yellow=True,
+ my_team_is_right=True,
+ friendly_robots=robots,
+ enemy_robots={},
+ ball=ball,
+ )
+
+
+# ---------------------------------------------------------------------------
+# Default behaviour (trusted_ir_robots=None — trust all)
+# ---------------------------------------------------------------------------
+
+
+def test_default_uses_ir_sensor_true():
+ refiner = RobotInfoRefiner()
+ frame = _make_frame({0: _make_robot(0, 0.0, 0.0, has_ball=False)}, ball_pos=(1.0, 0.0))
+ result = refiner.refine(frame, [RobotResponse(id=0, has_ball=True)])
+ assert result.friendly_robots[0].has_ball is True
+
+
+def test_default_uses_ir_sensor_false():
+ refiner = RobotInfoRefiner()
+ # Robot is right on top of the ball but IR says False — should respect IR.
+ frame = _make_frame({0: _make_robot(0, 0.0, 0.0, has_ball=True)}, ball_pos=(0.0, 0.0))
+ result = refiner.refine(frame, [RobotResponse(id=0, has_ball=False)])
+ assert result.friendly_robots[0].has_ball is False
+
+
+def test_empty_responses_returns_frame_unchanged():
+ refiner = RobotInfoRefiner()
+ frame = _make_frame({0: _make_robot(0, 0.0, 0.0, has_ball=True)})
+ assert refiner.refine(frame, []) is frame
+ assert refiner.refine(frame, None) is frame
+
+
+def test_unknown_robot_id_warns(recwarn):
+ refiner = RobotInfoRefiner()
+ frame = _make_frame({0: _make_robot(0, 0.0, 0.0)})
+ refiner.refine(frame, [RobotResponse(id=99, has_ball=True)])
+ assert any("99" in str(w.message) for w in recwarn.list)
+
+
+# ---------------------------------------------------------------------------
+# Trusted-IR allowlist: trusted robot uses IR, untrusted uses vision proximity
+# ---------------------------------------------------------------------------
+
+
+def test_trusted_robot_uses_ir_even_when_far_from_ball():
+ refiner = RobotInfoRefiner(trusted_ir_robots=frozenset({0}))
+ # Robot is 2 m from ball — no proximity, but IR says True.
+ frame = _make_frame({0: _make_robot(0, 0.0, 0.0)}, ball_pos=(2.0, 0.0))
+ result = refiner.refine(frame, [RobotResponse(id=0, has_ball=True)])
+ assert result.friendly_robots[0].has_ball is True
+
+
+def test_trusted_robot_respects_ir_false():
+ refiner = RobotInfoRefiner(trusted_ir_robots=frozenset({0}))
+ # Robot is touching the ball but IR says False — should respect IR.
+ frame = _make_frame({0: _make_robot(0, 0.0, 0.0)}, ball_pos=(0.0, 0.0))
+ result = refiner.refine(frame, [RobotResponse(id=0, has_ball=False)])
+ assert result.friendly_robots[0].has_ball is False
+
+
+def test_untrusted_robot_infers_true_when_close():
+ refiner = RobotInfoRefiner(trusted_ir_robots=frozenset()) # no robots trusted
+ # Place robot within capture distance; IR reports False (broken sensor).
+ frame = _make_frame({0: _make_robot(0, 0.0, 0.0)}, ball_pos=(0.05, 0.0))
+ result = refiner.refine(frame, [RobotResponse(id=0, has_ball=False)])
+ assert result.friendly_robots[0].has_ball is True
+
+
+def test_untrusted_robot_infers_false_when_far():
+ refiner = RobotInfoRefiner(trusted_ir_robots=frozenset())
+ # Robot is far from ball; IR reports True (broken sensor firing randomly).
+ frame = _make_frame({0: _make_robot(0, 0.0, 0.0)}, ball_pos=(1.0, 0.0))
+ result = refiner.refine(frame, [RobotResponse(id=0, has_ball=True)])
+ assert result.friendly_robots[0].has_ball is False
+
+
+def test_untrusted_robot_at_exact_capture_boundary():
+ refiner = RobotInfoRefiner(trusted_ir_robots=frozenset())
+ just_inside = _BALL_CAPTURE_DIST - 0.001
+ just_outside = _BALL_CAPTURE_DIST + 0.001
+
+ frame_in = _make_frame({0: _make_robot(0, 0.0, 0.0)}, ball_pos=(just_inside, 0.0))
+ assert refiner.refine(frame_in, [RobotResponse(id=0, has_ball=False)]).friendly_robots[0].has_ball is True
+
+ frame_out = _make_frame({0: _make_robot(0, 0.0, 0.0)}, ball_pos=(just_outside, 0.0))
+ assert refiner.refine(frame_out, [RobotResponse(id=0, has_ball=False)]).friendly_robots[0].has_ball is False
+
+
+def test_untrusted_robot_no_ball_in_frame_returns_false():
+ refiner = RobotInfoRefiner(trusted_ir_robots=frozenset())
+ frame = _make_frame({0: _make_robot(0, 0.0, 0.0)}, ball_pos=None)
+ result = refiner.refine(frame, [RobotResponse(id=0, has_ball=True)])
+ assert result.friendly_robots[0].has_ball is False
+
+
+# ---------------------------------------------------------------------------
+# Mixed team: one robot trusted, one not
+# ---------------------------------------------------------------------------
+
+
+def test_mixed_team_trusted_uses_ir_untrusted_uses_vision():
+ refiner = RobotInfoRefiner(trusted_ir_robots=frozenset({0}))
+ robots = {
+ 0: _make_robot(0, 0.0, 0.0), # trusted — far from ball
+ 1: _make_robot(1, 0.05, 0.0), # untrusted — close to ball
+ }
+ frame = _make_frame(robots, ball_pos=(0.0, 0.0))
+ responses = [
+ RobotResponse(id=0, has_ball=True), # IR says yes → trust it even though far
+ RobotResponse(id=1, has_ball=False), # IR says no → ignore, use proximity
+ ]
+ result = refiner.refine(frame, responses)
+ assert result.friendly_robots[0].has_ball is True # from IR
+ assert result.friendly_robots[1].has_ball is True # from vision (close enough)
+
+
+def test_untrusted_robot_missing_from_responses_uses_vision():
+ """Untrusted robot absent from responses this tick must not keep stale has_ball."""
+ refiner = RobotInfoRefiner(trusted_ir_robots=frozenset({0}))
+ robots = {
+ 0: _make_robot(0, 2.0, 0.0, has_ball=False), # trusted
+ 1: _make_robot(1, 0.05, 0.0, has_ball=True), # untrusted, stale True
+ }
+ frame = _make_frame(robots, ball_pos=(0.0, 0.0))
+ # Robot 1 did not respond this tick (non-blocking poll returned nothing for it).
+ responses = [RobotResponse(id=0, has_ball=False)]
+ result = refiner.refine(frame, responses)
+ # Robot 1 is close to ball → vision inference gives True (not stale, but correct)
+ assert result.friendly_robots[1].has_ball is True
+
+ # Now robot 1 is far from ball; stale value is True but inference should correct it.
+ robots_far = {
+ 0: _make_robot(0, 2.0, 0.0, has_ball=False),
+ 1: _make_robot(1, 1.0, 0.0, has_ball=True), # stale True, but now far
+ }
+ frame_far = _make_frame(robots_far, ball_pos=(0.0, 0.0))
+ result_far = refiner.refine(frame_far, [RobotResponse(id=0, has_ball=False)])
+ assert result_far.friendly_robots[1].has_ball is False # stale cleared by vision
diff --git a/utama_core/tests/strategy_runner/test_ball_placement_rsim.py b/utama_core/tests/strategy_runner/test_ball_placement_rsim.py
new file mode 100644
index 00000000..7274ea3a
--- /dev/null
+++ b/utama_core/tests/strategy_runner/test_ball_placement_rsim.py
@@ -0,0 +1,451 @@
+"""Integration tests for the ball placement feature.
+
+These tests verify that ``BallPlacementStrategy`` (and the underlying
+``BallPlacementStep`` behaviour) satisfies the core requirements of automatic
+ball placement:
+
+ 1. **Approach** — after BALL_PLACEMENT_YELLOW is issued, the placer robot
+ genuinely closes distance to the ball (robot starts far away so the test
+ cannot pass before the strategy acts).
+ 2. **Carry** — once the robot captures the ball (``robot.has_ball`` or
+ proximity), it moves toward ``designated_position`` with the dribbler on.
+ 3. **Clearance** — non-placer robots stay outside ``BALL_KEEP_OUT_DISTANCE``
+ throughout the placement phase. The placer ID is fixed to what was set in
+ reset_field so the check is independent of the implementation's own
+ selection logic.
+ 4. **Placer selection** — when robot 1 is closer to the ball than robot 0,
+ robot 1 is selected as the placer and robot 0 stays away.
+
+Setup
+-----
+- Exhibition Road field (``GREAT_EXHIBITION_FIELD_DIMS``, 4 m × 3 m).
+- 2v2 — two yellow robots (our team) controlled by ``BallPlacementStrategy``;
+ no enemy robots (they would interfere with deterministic positioning).
+- CustomReferee with the "simulation" profile so auto-advance fires and the
+ full BALL_PLACEMENT → DIRECT_FREE → NORMAL_START cycle can complete.
+
+Running
+-------
+ pixi run pytest utama_core/tests/strategy_runner/test_ball_placement_rsim.py -v
+
+Known gap
+---------
+The carry/dribbler phase transition (approach → has_ball → carry) is not tested
+end-to-end because the PID motion controller decelerates before physically
+touching the dribbler geometry, so ``robot.infrared`` (and therefore
+``robot.has_ball``) may not fire reliably. This requires motion controller
+tuning (slower final approach, behind-ball offset) before it can be tested.
+Test 2 currently covers carry progress as a proxy but does not assert dribbler
+state directly.
+
+All four tests are independent: they each start a fresh rsim episode, teleport
+robots and ball to known positions, issue BALL_PLACEMENT_YELLOW directly, and
+then observe the strategy's response over time.
+"""
+
+from __future__ import annotations
+
+import math
+from typing import Optional
+
+import py_trees
+
+from utama_core.config.field_params import GREAT_EXHIBITION_FIELD_DIMS
+from utama_core.config.referee_constants import BALL_KEEP_OUT_DISTANCE
+from utama_core.custom_referee import CustomReferee
+from utama_core.entities.game import Game
+from utama_core.entities.game.field import FieldBounds
+from utama_core.entities.referee.referee_command import RefereeCommand
+from utama_core.run.strategy_runner import StrategyRunner
+from utama_core.strategy.examples.ball_placement_strategy import BallPlacementStrategy
+from utama_core.team_controller.src.controllers import AbstractSimController
+from utama_core.tests.common.abstract_test_manager import (
+ AbstractTestManager,
+ TestingStatus,
+)
+
+# ---------------------------------------------------------------------------
+# Constants
+# ---------------------------------------------------------------------------
+
+# Designated placement target used across all tests (inside exhibition field)
+_TARGET = (0.8, 0.5)
+
+# Tolerance within which a robot is considered to have "approached" the ball
+_APPROACH_TOLERANCE = 0.5 # metres
+
+# Tolerance within which the placer is considered to be "heading to target"
+_TARGET_TOLERANCE = 0.6 # metres
+
+# Clearance margin: robots must be at least this far from the ball
+_CLEAR_MARGIN = 0.05 # metres — allowed slack inside keep-out boundary
+
+
+# ---------------------------------------------------------------------------
+# Shared runner factory
+# ---------------------------------------------------------------------------
+
+
+def _make_runner(referee: CustomReferee) -> StrategyRunner:
+ """Build a 2v2 StrategyRunner on the Exhibition Road field."""
+ return StrategyRunner(
+ strategy=BallPlacementStrategy(),
+ my_team_is_yellow=True,
+ my_team_is_right=True,
+ mode="rsim",
+ exp_friendly=2,
+ exp_enemy=0,
+ exp_ball=True,
+ full_field_dims=GREAT_EXHIBITION_FIELD_DIMS,
+ referee=referee,
+ )
+
+
+# ---------------------------------------------------------------------------
+# Test 1: placer robot drives toward the ball
+# ---------------------------------------------------------------------------
+
+
+class _ApproachBallManager(AbstractTestManager):
+ """Verify that after BALL_PLACEMENT_YELLOW the placer genuinely closes on the ball.
+
+ Robot 0 starts 1.5 m from the ball — well outside _APPROACH_TOLERANCE — so
+ the test cannot pass before the strategy acts. We record the initial distance
+ and require it to decrease by at least _APPROACH_PROGRESS metres, proving the
+ robot is actively moving toward the ball rather than already being close.
+ """
+
+ n_episodes = 1
+ # Robot 0 starts this far from the ball; must be > _APPROACH_TOLERANCE so the
+ # test cannot pass before the strategy has done anything.
+ _INITIAL_DIST = 1.5 # metres
+ # How much closer robot 0 must get before the test passes.
+ _APPROACH_PROGRESS = 0.8 # metres
+
+ def __init__(self, referee: CustomReferee) -> None:
+ super().__init__()
+ self._referee = referee
+ self.command_seen: bool = False
+ self.robot_approached_ball: bool = False
+ self._initial_dist: Optional[float] = None
+
+ def reset_field(self, sim_controller: AbstractSimController, game: Game) -> None:
+ # Ball at centre-left; robot 0 starts 1.5 m away along x so it must
+ # travel a meaningful distance before the test can pass.
+ sim_controller.teleport_ball(-0.5, 0.0)
+ sim_controller.teleport_robot(game.my_team_is_yellow, 0, 1.0, 0.0) # 1.5 m from ball
+ sim_controller.teleport_robot(game.my_team_is_yellow, 1, 0.8, -0.8) # kept away
+
+ self._referee.set_command(RefereeCommand.BALL_PLACEMENT_YELLOW, game.ts)
+ self._referee._state.ball_placement_target = _TARGET
+
+ def eval_status(self, game: Game) -> TestingStatus:
+ ref = game.referee
+ if ref is None:
+ return TestingStatus.IN_PROGRESS
+
+ if ref.referee_command == RefereeCommand.BALL_PLACEMENT_YELLOW:
+ self.command_seen = True
+
+ if not self.command_seen:
+ return TestingStatus.IN_PROGRESS
+
+ ball = game.ball
+ robot0 = game.friendly_robots.get(0)
+ if ball is None or robot0 is None:
+ return TestingStatus.IN_PROGRESS
+
+ dist = math.hypot(robot0.p.x - ball.p.x, robot0.p.y - ball.p.y)
+
+ if self._initial_dist is None:
+ self._initial_dist = dist
+ return TestingStatus.IN_PROGRESS
+
+ if self._initial_dist - dist >= self._APPROACH_PROGRESS:
+ self.robot_approached_ball = True
+ return TestingStatus.SUCCESS
+
+ return TestingStatus.IN_PROGRESS
+
+
+def test_placer_approaches_ball_after_command(headless: bool) -> None:
+ """After BALL_PLACEMENT_YELLOW, the placer robot closes at least 0.8 m toward the ball."""
+ referee = CustomReferee.from_profile_name("simulation")
+ runner = _make_runner(referee)
+ tm = _ApproachBallManager(referee)
+
+ passed = runner.run_test(tm, episode_timeout=20.0, rsim_headless=headless)
+
+ assert tm.command_seen, "BALL_PLACEMENT_YELLOW was never seen in game.referee"
+ assert tm.robot_approached_ball, (
+ f"Robot 0 did not close {_ApproachBallManager._APPROACH_PROGRESS} m toward the ball "
+ f"(started {_ApproachBallManager._INITIAL_DIST} m away)"
+ )
+ assert passed
+
+
+# ---------------------------------------------------------------------------
+# Test 2: placer moves toward designated_position (carry phase)
+# ---------------------------------------------------------------------------
+
+
+class _CarryToTargetManager(AbstractTestManager):
+ """Verify that the placer moves toward the designated position.
+
+ Robot 0 starts far from the ball, which sits between the robot and the
+ target. Because rsim's infrared sensor does not reliably fire, we test
+ the approach phase: the robot drives toward the ball (and therefore toward
+ the target), and must close at least _PROGRESS_THRESHOLD metres on the
+ target before the episode times out.
+ """
+
+ n_episodes = 1
+
+ # How much closer the placer must get to the target over the observation window
+ _PROGRESS_THRESHOLD = 0.2 # metres
+
+ def __init__(self, referee: CustomReferee) -> None:
+ super().__init__()
+ self._referee = referee
+ self.command_seen: bool = False
+ self.placer_made_progress: bool = False
+ self._initial_dist_to_target: Optional[float] = None
+
+ def reset_field(self, sim_controller: AbstractSimController, game: Game) -> None:
+ # Ball between robot 0 and the target so approach motion reduces
+ # distance to target, making progress measurable without has_ball.
+ sim_controller.teleport_ball(-0.6, 0.0)
+ sim_controller.teleport_robot(game.my_team_is_yellow, 0, -1.3, 0.0)
+ sim_controller.teleport_robot(game.my_team_is_yellow, 1, 0.6, -0.6)
+
+ self._referee.set_command(RefereeCommand.BALL_PLACEMENT_YELLOW, game.ts)
+ self._referee._state.ball_placement_target = _TARGET
+
+ def eval_status(self, game: Game) -> TestingStatus:
+ ref = game.referee
+ if ref is None:
+ return TestingStatus.IN_PROGRESS
+
+ if ref.referee_command == RefereeCommand.BALL_PLACEMENT_YELLOW:
+ self.command_seen = True
+
+ if not self.command_seen:
+ return TestingStatus.IN_PROGRESS
+
+ robot0 = game.friendly_robots.get(0)
+ if robot0 is None:
+ return TestingStatus.IN_PROGRESS
+
+ tx, ty = _TARGET
+ dist = math.hypot(robot0.p.x - tx, robot0.p.y - ty)
+
+ if self._initial_dist_to_target is None:
+ self._initial_dist_to_target = dist
+ return TestingStatus.IN_PROGRESS
+
+ improvement = self._initial_dist_to_target - dist
+ if improvement >= self._PROGRESS_THRESHOLD:
+ self.placer_made_progress = True
+ return TestingStatus.SUCCESS
+
+ return TestingStatus.IN_PROGRESS
+
+
+def test_placer_moves_toward_designated_position(headless: bool) -> None:
+ """After capturing the ball, the placer robot moves toward the designated position."""
+ referee = CustomReferee.from_profile_name("simulation")
+ runner = _make_runner(referee)
+ tm = _CarryToTargetManager(referee)
+
+ passed = runner.run_test(tm, episode_timeout=25.0, rsim_headless=headless)
+
+ assert tm.command_seen, "BALL_PLACEMENT_YELLOW was never seen in game.referee"
+ assert tm.placer_made_progress, "Placer robot did not make sufficient progress toward the designated position"
+ assert passed
+
+
+# ---------------------------------------------------------------------------
+# Test 3: non-placer robot stays outside keep-out distance
+# ---------------------------------------------------------------------------
+
+
+class _ClearanceManager(AbstractTestManager):
+ """Verify that non-placer robots respect the ball keep-out distance.
+
+ Robot 0 is placed closest to the ball and is therefore the expected placer.
+ Robot 1 starts inside the keep-out radius. The placer ID is fixed to 0
+ based on what reset_field sets up — it is NOT re-derived from the game state
+ using the same logic as the implementation, which would make the check
+ tautological. Robot 1 must clear and hold outside the keep-out zone.
+ """
+
+ n_episodes = 1
+ _PLACER_ID = 0 # fixed: robot 0 is closest to the ball in reset_field
+ _FRAMES_REQUIRED = 60
+
+ def __init__(self, referee: CustomReferee) -> None:
+ super().__init__()
+ self._referee = referee
+ self.command_seen: bool = False
+ self.clearance_achieved: bool = False
+ self._clear_frame_count: int = 0
+
+ def reset_field(self, sim_controller: AbstractSimController, game: Game) -> None:
+ # Ball at centre so there is plenty of room in all directions for robot 1
+ # to reach 0.8 m clearance. Robot 0 is 0.2 m from the ball → it is the
+ # placer. Robot 1 starts 0.3 m from the ball (inside keep-out) → must clear.
+ sim_controller.teleport_ball(0.0, 0.0)
+ sim_controller.teleport_robot(game.my_team_is_yellow, 0, 0.2, 0.0)
+ sim_controller.teleport_robot(game.my_team_is_yellow, 1, 0.0, 0.3) # within keep-out
+
+ self._referee.set_command(RefereeCommand.BALL_PLACEMENT_YELLOW, game.ts)
+ self._referee._state.ball_placement_target = _TARGET
+
+ def eval_status(self, game: Game) -> TestingStatus:
+ ref = game.referee
+ if ref is None:
+ return TestingStatus.IN_PROGRESS
+
+ if ref.referee_command == RefereeCommand.BALL_PLACEMENT_YELLOW:
+ self.command_seen = True
+
+ if not self.command_seen:
+ return TestingStatus.IN_PROGRESS
+
+ ball = game.ball
+ if ball is None:
+ return TestingStatus.IN_PROGRESS
+
+ threshold = BALL_KEEP_OUT_DISTANCE - _CLEAR_MARGIN
+
+ non_placers_clear = all(
+ math.hypot(robot.p.x - ball.p.x, robot.p.y - ball.p.y) >= threshold
+ for rid, robot in game.friendly_robots.items()
+ if rid != self._PLACER_ID
+ )
+
+ if non_placers_clear:
+ self._clear_frame_count += 1
+ else:
+ self._clear_frame_count = 0
+
+ if self._clear_frame_count >= self._FRAMES_REQUIRED:
+ self.clearance_achieved = True
+ return TestingStatus.SUCCESS
+
+ return TestingStatus.IN_PROGRESS
+
+
+def test_non_placer_clears_ball_keep_out_zone(headless: bool) -> None:
+ """Non-placer robot (id=1) clears and holds outside the ball keep-out radius."""
+ referee = CustomReferee.from_profile_name("simulation")
+ runner = _make_runner(referee)
+ tm = _ClearanceManager(referee)
+
+ passed = runner.run_test(tm, episode_timeout=20.0, rsim_headless=headless)
+
+ assert tm.command_seen, "BALL_PLACEMENT_YELLOW was never seen in game.referee"
+ assert tm.clearance_achieved, (
+ f"Robot 1 did not sustain clearance outside "
+ f"BALL_KEEP_OUT_DISTANCE ({BALL_KEEP_OUT_DISTANCE} m) "
+ f"for {_ClearanceManager._FRAMES_REQUIRED} consecutive frames"
+ )
+ assert passed
+
+
+# ---------------------------------------------------------------------------
+# Test 4: placer selection — robot 1 closer to ball is chosen as placer
+# ---------------------------------------------------------------------------
+
+
+class _PlacerSelectionManager(AbstractTestManager):
+ """Verify that the robot closest to the ball is selected as the placer.
+
+ Robot 1 starts closer to the ball than robot 0. The correct behaviour is:
+ - Robot 1 (the placer) closes distance to the ball.
+ - Robot 0 (the non-placer) does NOT drive toward the ball — it should either
+ hold position or move away to clear the keep-out zone.
+
+ This test exercises the min(distance_to_ball) selection logic with robot 0
+ as the non-placer, which the original tests never covered.
+ """
+
+ n_episodes = 1
+ _PLACER_ID = 1 # robot 1 is closer to the ball in reset_field
+ _NON_PLACER_ID = 0
+ _PLACER_PROGRESS = 0.2 # metres robot 1 must close toward the ball
+ _NON_PLACER_GRACE = 0.3 # seconds before we start checking robot 0
+
+ def __init__(self, referee: CustomReferee) -> None:
+ super().__init__()
+ self._referee = referee
+ self.command_seen: bool = False
+ self.placer_approached: bool = False
+ self.non_placer_stayed_away: bool = False
+ self._placer_initial_dist: Optional[float] = None
+ self._non_placer_initial_dist: Optional[float] = None
+ self._command_ts: Optional[float] = None
+
+ def reset_field(self, sim_controller: AbstractSimController, game: Game) -> None:
+ # Ball at centre; robot 1 is 0.3 m from ball, robot 0 is 1.2 m away.
+ sim_controller.teleport_ball(0.0, 0.0)
+ sim_controller.teleport_robot(game.my_team_is_yellow, 0, -1.2, 0.0) # far — non-placer
+ sim_controller.teleport_robot(game.my_team_is_yellow, 1, 0.3, 0.0) # close — placer
+
+ self._referee.set_command(RefereeCommand.BALL_PLACEMENT_YELLOW, game.ts)
+ self._referee._state.ball_placement_target = _TARGET
+
+ def eval_status(self, game: Game) -> TestingStatus:
+ ref = game.referee
+ if ref is None:
+ return TestingStatus.IN_PROGRESS
+
+ if ref.referee_command == RefereeCommand.BALL_PLACEMENT_YELLOW:
+ if not self.command_seen:
+ self.command_seen = True
+ self._command_ts = game.ts
+
+ if not self.command_seen:
+ return TestingStatus.IN_PROGRESS
+
+ ball = game.ball
+ robot1 = game.friendly_robots.get(self._PLACER_ID)
+ robot0 = game.friendly_robots.get(self._NON_PLACER_ID)
+ if ball is None or robot1 is None or robot0 is None:
+ return TestingStatus.IN_PROGRESS
+
+ dist1 = math.hypot(robot1.p.x - ball.p.x, robot1.p.y - ball.p.y)
+ dist0 = math.hypot(robot0.p.x - ball.p.x, robot0.p.y - ball.p.y)
+
+ if self._placer_initial_dist is None:
+ self._placer_initial_dist = dist1
+ self._non_placer_initial_dist = dist0
+
+ # Placer (robot 1) must close on the ball.
+ if self._placer_initial_dist - dist1 >= self._PLACER_PROGRESS:
+ self.placer_approached = True
+
+ # Non-placer (robot 0) must not move closer to the ball than it started.
+ # Allow a grace period for the command to propagate, then check.
+ grace_elapsed = (game.ts - self._command_ts) >= self._NON_PLACER_GRACE
+ if grace_elapsed and dist0 >= self._non_placer_initial_dist - 0.1:
+ self.non_placer_stayed_away = True
+
+ if self.placer_approached and self.non_placer_stayed_away:
+ return TestingStatus.SUCCESS
+
+ return TestingStatus.IN_PROGRESS
+
+
+def test_closer_robot_selected_as_placer(headless: bool) -> None:
+ """Robot 1 (closer to ball) is selected as placer; robot 0 does not drive toward ball."""
+ referee = CustomReferee.from_profile_name("simulation")
+ runner = _make_runner(referee)
+ tm = _PlacerSelectionManager(referee)
+
+ passed = runner.run_test(tm, episode_timeout=20.0, rsim_headless=headless)
+
+ assert tm.command_seen, "BALL_PLACEMENT_YELLOW was never seen in game.referee"
+ assert tm.placer_approached, "Robot 1 (closer to ball) did not drive toward the ball — wrong placer selected"
+ assert tm.non_placer_stayed_away, "Robot 0 (non-placer) moved toward the ball — it should clear away"
+ assert passed
diff --git a/utama_core/tests/strategy_runner/test_referee_rsim.py b/utama_core/tests/strategy_runner/test_referee_rsim.py
index 67f3877e..8389538e 100644
--- a/utama_core/tests/strategy_runner/test_referee_rsim.py
+++ b/utama_core/tests/strategy_runner/test_referee_rsim.py
@@ -177,15 +177,10 @@ def test_ball_placement_robot_approaches_designated_position(headless):
class _DirectFreeOursManager(AbstractTestManager):
- """Ball exits the side boundary with no friendly robot nearby → DIRECT_FREE_YELLOW (ours).
-
- With no robot close enough to the ball to register a friendly last-touch,
- OutOfBoundsRule defaults to DIRECT_FREE_YELLOW. We verify the kicker
- drives toward the (now out-of-bounds) ball.
- """
+ """DIRECT_FREE_YELLOW is injected directly; kicker must drive toward the ball."""
n_episodes = 1
- APPROACH_TOLERANCE = 0.6 # slightly wider: ball may be just outside boundary
+ APPROACH_TOLERANCE = 0.6
def __init__(self, referee: CustomReferee):
super().__init__()
@@ -194,13 +189,12 @@ def __init__(self, referee: CustomReferee):
self.robot_near_ball: bool = False
def reset_field(self, sim_controller: AbstractSimController, game: Game):
- # Keep all robots well away from the ball so last-touch is unknown → DIRECT_FREE_YELLOW
sim_controller.teleport_robot(game.my_team_is_yellow, 0, -1.5, 0.0)
sim_controller.teleport_robot(game.my_team_is_yellow, 1, -2.0, 0.5)
sim_controller.teleport_robot(game.my_team_is_yellow, 2, -2.0, -0.5)
- # Ball heading out the top sideline
- sim_controller.teleport_ball(0.0, 2.5, vx=0.0, vy=2.5)
- self._referee.set_command(RefereeCommand.FORCE_START, game.ts)
+ sim_controller.teleport_ball(0.0, 0.0)
+ # Inject directly — bypass OOB detection which now routes through ball placement first.
+ self._referee.force_command(RefereeCommand.DIRECT_FREE_YELLOW, game.ts)
def eval_status(self, game: Game) -> TestingStatus:
ref = game.referee
@@ -245,34 +239,26 @@ def test_direct_free_kick_ours_robot_drives_to_ball(headless):
class _DirectFreeTheirsManager(AbstractTestManager):
- """A robot starts inside the keep-out radius; DIRECT_FREE_BLUE (theirs) is issued.
-
- We place robot 0 right next to the ball before it exits so last-touch registers
- as friendly → OutOfBoundsRule issues DIRECT_FREE_BLUE (opponent's free kick).
- The test verifies that all robots end up outside the keep-out radius.
- """
+ """DIRECT_FREE_BLUE is injected directly; all robots must clear the keep-out radius."""
n_episodes = 1
- # All robots must clear beyond this radius from the ball position.
- CLEAR_TOLERANCE = 0.1 # allowed margin inside keep-out (robots should be well clear)
+ CLEAR_TOLERANCE = 0.1
def __init__(self, referee: CustomReferee):
super().__init__()
self._referee = referee
self.direct_free_seen: bool = False
self.robots_cleared: bool = False
- # We record the ball position when DIRECT_FREE_BLUE fires to check clearing.
self._ball_pos_at_call: Optional[tuple[float, float]] = None
def reset_field(self, sim_controller: AbstractSimController, game: Game):
- # Robot 0 is placed right next to the ball — it will register as last-toucher.
- sim_controller.teleport_robot(game.my_team_is_yellow, 0, 0.0, 2.4)
- # Robots 1 and 2 also start near the ball path — both inside keep-out.
- sim_controller.teleport_robot(game.my_team_is_yellow, 1, 0.1, 2.3)
- sim_controller.teleport_robot(game.my_team_is_yellow, 2, -0.1, 2.3)
- # Ball heading out the top sideline; robot 0 is close enough for last-touch
- sim_controller.teleport_ball(0.0, 2.5, vx=0.0, vy=2.5)
- self._referee.set_command(RefereeCommand.FORCE_START, game.ts)
+ # All robots start inside keep-out radius around the ball.
+ sim_controller.teleport_robot(game.my_team_is_yellow, 0, 0.0, 0.3)
+ sim_controller.teleport_robot(game.my_team_is_yellow, 1, 0.1, -0.3)
+ sim_controller.teleport_robot(game.my_team_is_yellow, 2, -0.1, 0.3)
+ sim_controller.teleport_ball(0.0, 0.0)
+ # Inject directly — bypass OOB detection which now routes through ball placement first.
+ self._referee.force_command(RefereeCommand.DIRECT_FREE_BLUE, game.ts)
def eval_status(self, game: Game) -> TestingStatus:
ref = game.referee
diff --git a/utama_core/tests/strategy_runner/test_runner_misconfig.py b/utama_core/tests/strategy_runner/test_runner_misconfig.py
index 1a04f9e4..3e458ad0 100644
--- a/utama_core/tests/strategy_runner/test_runner_misconfig.py
+++ b/utama_core/tests/strategy_runner/test_runner_misconfig.py
@@ -132,7 +132,9 @@ def __init__(self, buffers, on_geometry=None):
assert len(started[0]) == 1
-def test_setup_vision_and_referee_starts_both_receivers_when_referee_official(monkeypatch):
+def test_setup_vision_and_referee_starts_both_receivers_when_referee_official(
+ monkeypatch,
+):
from utama_core.run import strategy_runner as runner_mod
started = []
@@ -262,3 +264,194 @@ def test_strategy_runner_bounds_outside_non_standard_field_dims():
full_field_dims=GREAT_EXHIBITION_FIELD_DIMS,
field_bounds=too_large_for_custom_dims,
)
+
+
+def test_validate_vision_to_cmd_mapping_real_not_controlled():
+ runner = SimpleNamespace(
+ mode=Mode.REAL,
+ my_team_is_yellow=True,
+ opp=None,
+ exp_friendly=3,
+ )
+ result = StrategyRunner._validate_vision_to_cmd_mapping(runner, None, True)
+ assert result == {}
+
+
+def test_validate_vision_to_cmd_mapping_pvp_opp_mapping_missing():
+ runner = SimpleNamespace(
+ mode=Mode.REAL,
+ my_team_is_yellow=True,
+ opp=True,
+ exp_friendly=3,
+ exp_enemy=3,
+ )
+ with pytest.raises(ValueError, match="required for both teams in real PVP"):
+ StrategyRunner._validate_vision_to_cmd_mapping(runner, None, False)
+
+
+def test_validate_vision_to_cmd_mapping_pvp_friendly_mapping_missing():
+ runner = SimpleNamespace(
+ mode=Mode.REAL,
+ my_team_is_yellow=True,
+ opp=True,
+ exp_friendly=3,
+ exp_enemy=3,
+ )
+ with pytest.raises(ValueError, match="required for both teams in real PVP"):
+ StrategyRunner._validate_vision_to_cmd_mapping(runner, None, True)
+
+
+def test_validate_vision_to_cmd_mapping_type_error():
+ runner = SimpleNamespace(
+ mode=Mode.REAL,
+ my_team_is_yellow=True,
+ opp=None,
+ exp_friendly=3,
+ )
+ with pytest.raises(TypeError, match="must be a dictionary"):
+ StrategyRunner._validate_vision_to_cmd_mapping(runner, [], True)
+
+
+def test_validate_vision_to_cmd_mapping_ignored_warning():
+ import warnings
+
+ runner = SimpleNamespace(
+ mode=Mode.REAL,
+ my_team_is_yellow=True,
+ opp=None,
+ exp_friendly=3,
+ )
+ with pytest.warns(UserWarning, match="vision_to_cmd_mapping is provided but will be ignored"):
+ StrategyRunner._validate_vision_to_cmd_mapping(runner, {0: 0}, False)
+
+
+def test_validate_vision_to_cmd_mapping_incorrect_length_friendly():
+ runner = SimpleNamespace(
+ mode=Mode.REAL,
+ my_team_is_yellow=True,
+ opp=True,
+ exp_friendly=3,
+ exp_enemy=3,
+ )
+ with pytest.raises(ValueError, match="must include all expected friendly robots"):
+ StrategyRunner._validate_vision_to_cmd_mapping(runner, {0: 0, 1: 1}, True)
+
+
+def test_validate_vision_to_cmd_mapping_incorrect_length_enemy():
+ runner = SimpleNamespace(
+ mode=Mode.REAL,
+ my_team_is_yellow=True,
+ opp=True,
+ exp_friendly=3,
+ exp_enemy=3,
+ )
+ with pytest.raises(ValueError, match="must include all expected opponent robots"):
+ StrategyRunner._validate_vision_to_cmd_mapping(runner, {0: 0, 1: 1}, False)
+
+
+def test_validate_vision_to_cmd_mapping_invalid_ids():
+ from utama_core.config.physical_constants import MAX_ROBOT_ID
+
+ runner = SimpleNamespace(
+ mode=Mode.REAL,
+ my_team_is_yellow=True,
+ opp=None,
+ exp_friendly=3,
+ )
+ with pytest.raises(TypeError, match="must map integers to integers"):
+ StrategyRunner._validate_vision_to_cmd_mapping(runner, {0: "0"}, True)
+ with pytest.raises(ValueError, match="cannot have negative IDs"):
+ StrategyRunner._validate_vision_to_cmd_mapping(runner, {-1: 0}, True)
+ with pytest.raises(ValueError, match="cannot have vision IDs greater than"):
+ StrategyRunner._validate_vision_to_cmd_mapping(runner, {MAX_ROBOT_ID + 1: 0}, True)
+ with pytest.raises(ValueError, match="cannot have command IDs greater than 255"):
+ StrategyRunner._validate_vision_to_cmd_mapping(runner, {0: 256}, True)
+
+
+def test_validate_vision_to_cmd_mapping_sim_mode_raises():
+ runner = SimpleNamespace(
+ mode=Mode.RSIM,
+ my_team_is_yellow=True,
+ opp=None,
+ )
+ with pytest.raises(ValueError, match="should not be provided in simulation modes"):
+ StrategyRunner._validate_vision_to_cmd_mapping(runner, {0: 0}, True)
+
+
+def test_trusted_ir_robots_yellow_team_is_my_team():
+ """yellow_trusted_ir_robots routes to my refiner when my_team_is_yellow=True."""
+ runner = StrategyRunner(
+ strategy=DummyStrategy(),
+ my_team_is_yellow=True,
+ my_team_is_right=True,
+ mode="rsim",
+ exp_friendly=3,
+ exp_enemy=3,
+ yellow_trusted_ir_robots=frozenset({0, 1}),
+ )
+ assert runner.my.robot_info_refiner._trusted_ir_robots == frozenset({0, 1})
+ assert runner.opp is None
+
+
+def test_trusted_ir_robots_blue_team_is_my_team():
+ """blue_trusted_ir_robots routes to my refiner when my_team_is_yellow=False."""
+ runner = StrategyRunner(
+ strategy=DummyStrategy(),
+ my_team_is_yellow=False,
+ my_team_is_right=True,
+ mode="rsim",
+ exp_friendly=3,
+ exp_enemy=3,
+ blue_trusted_ir_robots=frozenset({2}),
+ )
+ assert runner.my.robot_info_refiner._trusted_ir_robots == frozenset({2})
+
+
+def test_trusted_ir_robots_both_teams_pvp():
+ """Both colour params route correctly in PVP mode."""
+ runner = StrategyRunner(
+ strategy=DummyStrategy(),
+ opp_strategy=DummyStrategy(),
+ my_team_is_yellow=True,
+ my_team_is_right=True,
+ mode="rsim",
+ exp_friendly=3,
+ exp_enemy=3,
+ yellow_trusted_ir_robots=frozenset({0}),
+ blue_trusted_ir_robots=frozenset({1}),
+ )
+ assert runner.my.robot_info_refiner._trusted_ir_robots == frozenset({0})
+ assert runner.opp.robot_info_refiner._trusted_ir_robots == frozenset({1})
+
+
+def test_trusted_ir_robots_none_by_default():
+ """Default None means trust all IR sensors (backwards-compatible)."""
+ runner = StrategyRunner(
+ strategy=DummyStrategy(),
+ my_team_is_yellow=True,
+ my_team_is_right=True,
+ mode="rsim",
+ exp_friendly=3,
+ exp_enemy=3,
+ )
+ assert runner.my.robot_info_refiner._trusted_ir_robots is None
+
+
+def test_check_no_cmd_duplicate_if_transmission_sharing():
+ runner = SimpleNamespace()
+
+ # Test valid non-overlapping mappings
+ yellow_mapping = {0: 1, 1: 2}
+ blue_mapping = {0: 3, 1: 4}
+ StrategyRunner._check_no_cmd_duplicate_if_transmission_sharing(runner, yellow_mapping, blue_mapping)
+
+ # Test collision in same team
+ yellow_mapping_duplicate = {0: 1, 1: 1}
+ with pytest.raises(ValueError, match="cannot have overlapping command IDs"):
+ StrategyRunner._check_no_cmd_duplicate_if_transmission_sharing(runner, yellow_mapping_duplicate, blue_mapping)
+
+ # Test collision across teams
+ yellow_mapping = {0: 1, 1: 2}
+ blue_mapping_overlap = {0: 2, 1: 4}
+ with pytest.raises(ValueError, match="cannot have overlapping command IDs"):
+ StrategyRunner._check_no_cmd_duplicate_if_transmission_sharing(runner, yellow_mapping, blue_mapping_overlap)