diff --git a/docs/STRATEGY_DEVELOPMENT.md b/docs/STRATEGY_DEVELOPMENT.md index 67c07c91..78a81e71 100644 --- a/docs/STRATEGY_DEVELOPMENT.md +++ b/docs/STRATEGY_DEVELOPMENT.md @@ -178,17 +178,10 @@ whoever runs it. spatially. Use raw frames only for an exact number once the picture has localized the issue. - **`render_clip()`** (`analysis/render_clip.py`) — MP4 of a window for humans, ball-following or full-pitch camera; sim ball teleports are never shown. Needs `ffmpeg`. Commands behind -<<<<<<< HEAD committed clips: `assets/clips/README.md`. - **`docs/strategies.md`** — every factory, whether a round-robin plays it or it is retired, and the latest results. - **`tools/evaluation/round_robin.py`** — round-robin runner (`--max-workers N`, `--both-sides`, -======= - committed clips: `assets/clips/README.md`. -- **`docs/strategies.md`** — every factory's status and the latest results. `baseline` - strategies aren't meant to win; don't tune them to. -- **`tools/evaluation/round_robin.py`** — round-robin runner (`--max-workers N`, `--both-sides`, ->>>>>>> repo-cleanup `--strict`, `--stop-at-first-stall`, `--fuzz-restarts SEED`, `--fuzz-interval LO HI`, `--no-save`, `--pair A B` for one fixture with A as config_a — reruns a stalled match from a round-robin; see the determinism caveat below). diff --git a/docs/strategies.md b/docs/strategies.md index f52557e2..c538fb64 100644 --- a/docs/strategies.md +++ b/docs/strategies.md @@ -53,6 +53,47 @@ against the round-robin's top. `default`: Everyone runs `PassAndShootTactic`; only 2 robots are ever commanded (see bugs). Excluded from round-robin discovery. +## Strategy search, 2026-10-11 + +Six `split_shape` variants, each one idea, in an 18-config round-robin with the 12 above +(`replays/tournament_20261012_095359/summary.json`, 153 matches of 600 s, after the +`LeadAndSupportTactic` shot-target, `turn_on_spot` blocked-pivot, `DecoyOverloadTactic` +finish-release and `split_wall` keep-out fixes). rsim is deterministic, so this is one match per +pairing: the order among the six variants changed with every fix to shared code and is not +evidence on its own. Every variant finished above `split_shape`, which beat none of them. + +| Strategy | Idea | W-D-L | GF-GA | Points per match | vs `split_shape` | +|---|---|---|---|---|---| +| `split_sticky` | Possession hysteresis on the attack/defense split | 15-0-2 | 136-83 | 2.65 | 8-7 | +| `split_block` | `BlockShapeTactic` defense | 14-1-2 | 144-82 | 2.53 | 8-8 | +| `split_wall` | Two-robot wall at the opponent's direct free kicks (`referee_overrides`) | 14-1-2 | 119-75 | 2.53 | 7-6 | +| `split_outlet` | One robot held high on the weak side while defending (`OutletTactic`) | 14-0-3 | 127-85 | 2.47 | 8-7 | +| `split_outlet_wall` | `split_outlet` plus `split_wall`'s free-kick wall | 13-1-3 | 127-74 | 2.35 | 7-2 | +| `split_press` | Without the ball, a 2-robot `PressAndContainTactic` instead of a lone chaser | 12-2-3 | 138-73 | 2.24 | 8-8 | +| `split_shape` | (reference) | 10-3-4 | 113-76 | 1.94 | | + +**Head to head, 2026-10-12.** Each pairing played its 8 deterministic side/kickoff settings and +20 seeded noisy matches (side fixed, kickoff alternating; vision noise 3 mm and 1° per detection, +2% of detections dropped per frame; a seed replays bit for bit). Goal difference per match for +the first-named strategy, ± one standard error: + +| Pairing | 8 settings, no noise | 20 noisy seeds | +|---|---|---| +| `split_block` vs `split_shape` | 6-2-0, +2.4 ± 0.9 | 11-1-8, +0.5 ± 0.6 | +| `split_outlet` vs `split_shape` | 4-0-4, −1.3 ± 1.3 | 8-4-8, −0.6 ± 0.8 | +| `split_sticky` vs `split_block` | 3-1-4, +0.0 ± 1.4 | 8-4-8, −0.3 ± 0.5 | +| `split_shape` vs `counter_flow` | 7-1-0, +4.0 ± 0.7 | 17-1-2, +3.1 ± 0.5 | + +No variant is shown better than `split_shape`: `split_block`'s edge in the 8 deterministic +settings, the setting it was picked on, did not hold over 20 noisy matches. The variants are +within about half a goal per match of each other. Noise keeps a real gap (`counter_flow`) and +gives each match about the same spread as the deterministic settings (2-4 goals), so it adds +matches without adding noise to each one. `split_shape` stays the champion. + +6 of the round-robin's 153 matches stalled (COMMITTED_FROZEN): two carriers pinning the ball +between them until the referee's no-progress stop; the cause of the stuck turn is not yet found. +None of the 80 noisy matches stalled (about 3 expected at the round-robin's rate). + ## Latest results **Round-robin** (2026-10-10, strategy-guard at a17e2791, the 12 strategies above, 66 matches, diff --git a/utama_core/skills/src/utils/move_utils.py b/utama_core/skills/src/utils/move_utils.py index 0f7b7215..11fff1d6 100644 --- a/utama_core/skills/src/utils/move_utils.py +++ b/utama_core/skills/src/utils/move_utils.py @@ -97,6 +97,7 @@ def turn_on_spot( # blocked branch below for why holding position doesn't actually yield a # working turn. move_target = robot.p + blocked = False if in_contact: held_turn = move( game=game, @@ -141,6 +142,7 @@ def turn_on_spot( away_dist = away.mag() if away_dist > 1e-6: move_target = robot.p + away * (_PIVOT_CLEARANCE_M / away_dist) + blocked = True break turn = move( @@ -152,7 +154,9 @@ def turn_on_spot( dribbling=dribbling, ) - if in_contact: + # When blocked, keep move()'s lateral command: it drives toward the escape + # target, and overriding it with the pivot term would push back into the blocker. + if in_contact and not blocked: local_left_vel = -turn.angular_vel * PIVOT_RADIUS turn = turn._replace(local_left_vel=local_left_vel) diff --git a/utama_core/strategy/kernel_strategy.py b/utama_core/strategy/kernel_strategy.py index 63881430..89e777d3 100644 --- a/utama_core/strategy/kernel_strategy.py +++ b/utama_core/strategy/kernel_strategy.py @@ -54,7 +54,15 @@ build_score_aware_zone_flow_kernel_strategy, ) from utama_core.strategy.shadow_switch import build_shadow_switch_kernel_strategy +from utama_core.strategy.split_block import build_split_block_kernel_strategy +from utama_core.strategy.split_outlet import build_split_outlet_kernel_strategy +from utama_core.strategy.split_outlet_wall import ( + build_split_outlet_wall_kernel_strategy, +) +from utama_core.strategy.split_press import build_split_press_kernel_strategy from utama_core.strategy.split_shape import build_split_shape_kernel_strategy +from utama_core.strategy.split_sticky import build_split_sticky_kernel_strategy +from utama_core.strategy.split_wall import build_split_wall_kernel_strategy from utama_core.strategy.switch_of_play import build_switch_of_play_kernel_strategy from utama_core.strategy.three_slot import ( _three_way_picker, diff --git a/utama_core/strategy/split_block.py b/utama_core/strategy/split_block.py new file mode 100644 index 00000000..312b1b35 --- /dev/null +++ b/utama_core/strategy/split_block.py @@ -0,0 +1,93 @@ +"""The `split_block` kernel strategy: `build_split_block_kernel_strategy` and the pickers only it uses. + +Same attack, split and picker as `split_shape`; the defense slot runs `BlockShapeTactic` +(a space-denying lane screen) instead of `ShadowAndMarkTactic` (greedy man-marking), because +counter_flow, the fewest-goals-conceded strategy, defends that way. +""" + +from __future__ import annotations + +from typing import Optional + +from utama_core.engine.context import TickContext +from utama_core.engine.strategy import Strategy as KernelSchedulerStrategy +from utama_core.engine.tactic import RobotId +from utama_core.entities.data.object import TeamType +from utama_core.entities.game import Game +from utama_core.motion_planning.src.common.motion_controller import MotionController +from utama_core.strategy.pickers import carrier_first +from utama_core.tactics.block_shape import BlockShapeTactic +from utama_core.tactics.lead_and_support import LeadAndSupportTactic + + +def _possession_split_picker( + game: Game, + free_robots: frozenset[RobotId], + prev_partition: Optional[dict[str, frozenset[RobotId]]], + available_tactic_ids: frozenset[str], +) -> dict[str, frozenset[RobotId]]: + """Whichever side is closer to the ball decides posture; the split is fixed once decided. + + Neither `LeadAndSupportTactic` nor `ShadowAndMarkTactic` overrides + `applicable()`, so a slot is missing from `available_tactic_ids` only + while it's pinned by a commitment — its share then goes to the other slot. + + Deliberately the simplest rule that gives the split-shape scheduler + something real to react to, not a scored/tunable allocator (see the + design doc's stance against bid/fitness-scoring machinery) — one signal + (which team is closer to the ball), two fixed splits. Ties and an + unreadable proximity lookup default to the more conservative + (defense-heavy) split. + + Only emits a key for a tactic it is actually assigning free robots to — + never a zero-robot entry, since `Strategy` treats every key in a + `Partitioner`'s return value as "I am claiming this tactic id right now," + and a present-but-empty entry for a tactic committed and pinned + elsewhere would collide with that pin. + """ + ordered = carrier_first(game, free_robots) + if not ordered: + return {} + + _friendly_closest, friendly_dist = game.proximity_lookup.closest_to_ball(team_type_filter=TeamType.FRIENDLY) + _enemy_closest, enemy_dist = game.proximity_lookup.closest_to_ball(team_type_filter=TeamType.ENEMY) + friendly_has_ball_edge = friendly_dist < enemy_dist + + if "attack" not in available_tactic_ids: + return {"defense": frozenset(ordered)} if "defense" in available_tactic_ids else {} + if "defense" not in available_tactic_ids: + return {"attack": frozenset(ordered)} + + attack_count = (len(ordered) + 1) // 2 + 1 if friendly_has_ball_edge else len(ordered) // 2 - 1 + attack_count = max(0, min(len(ordered), attack_count)) + + partition = {} + if attack_count > 0: + partition["attack"] = frozenset(ordered[:attack_count]) + if attack_count < len(ordered): + partition["defense"] = frozenset(ordered[attack_count:]) + return partition + + +def build_split_block_kernel_strategy(outfield_robot_ids: tuple[int, ...]): + """5-robot (or fewer) split-block `Strategy` factory. + + Wires `LeadAndSupportTactic` ("attack") and `BlockShapeTactic` + ("defense") as two concurrently active slots, split by + `_possession_split_picker` (identical to split_shape's). + + Returns a `build_kernel_strategy(motion_controller)` + callable suitable for `AbstractStrategy`'s constructor argument of the + same name. + """ + + def _build(motion_controller: MotionController) -> KernelSchedulerStrategy: + ctx = TickContext(motion_controller=motion_controller) + return KernelSchedulerStrategy( + tactics={"attack": LeadAndSupportTactic(), "defense": BlockShapeTactic()}, + partitioner=_possession_split_picker, + outfield_robot_ids=outfield_robot_ids, + ctx=ctx, + ) + + return _build diff --git a/utama_core/strategy/split_outlet.py b/utama_core/strategy/split_outlet.py new file mode 100644 index 00000000..9e586479 --- /dev/null +++ b/utama_core/strategy/split_outlet.py @@ -0,0 +1,100 @@ +"""The `split_outlet` kernel strategy: `split_shape` plus a counter-attack outlet slot. + +`split_shape` gives its defence one robot whenever it holds the ball edge; `split_outlet` +keeps that split unchanged. Without the ball edge (and with 3 or more robots) it takes +one robot from the defence for an `OutletTactic` slot, which holds on the weak side of +the attacking half so a turnover has a receiver ahead of the defensive line. The outlet +robot rejoins the attack whenever the ball edge is ours. +""" + +from __future__ import annotations + +from typing import Optional + +from utama_core.engine.context import TickContext +from utama_core.engine.strategy import Strategy as KernelSchedulerStrategy +from utama_core.engine.tactic import RobotId +from utama_core.entities.data.object import TeamType +from utama_core.entities.game import Game +from utama_core.motion_planning.src.common.motion_controller import MotionController +from utama_core.strategy.pickers import carrier_first +from utama_core.tactics.lead_and_support import LeadAndSupportTactic +from utama_core.tactics.outlet import OutletTactic +from utama_core.tactics.shadow_and_mark import ShadowAndMarkTactic + + +def _split_outlet_picker( + game: Game, + free_robots: frozenset[RobotId], + prev_partition: Optional[dict[str, frozenset[RobotId]]], + available_tactic_ids: frozenset[str], +) -> dict[str, frozenset[RobotId]]: + """`split_shape`'s split, with an outlet slot taken from the defence when we lack the ball edge. + + Counts (n = robots): with the ball edge, or with fewer than 3 robots, the split is + `split_shape`'s exactly and the outlet slot is never used. Otherwise attack 1, + outlet 1, defence n - 2. A slot missing from `available_tactic_ids` hands its + robots to defence, then attack, then outlet. + + Only emits a key for a slot it assigns robots to, never a zero-robot entry (see + `split_shape`'s picker for why). + """ + ordered = carrier_first(game, free_robots) + if not ordered: + return {} + n = len(ordered) + + _friendly_closest, friendly_dist = game.proximity_lookup.closest_to_ball(team_type_filter=TeamType.FRIENDLY) + _enemy_closest, enemy_dist = game.proximity_lookup.closest_to_ball(team_type_filter=TeamType.ENEMY) + friendly_has_ball_edge = friendly_dist < enemy_dist + + if friendly_has_ball_edge or n < 3: + attack_count = (n + 1) // 2 + 1 if friendly_has_ball_edge else n // 2 - 1 + attack_count = max(0, min(n, attack_count)) + counts = {"attack": attack_count, "outlet": 0, "defense": n - attack_count} + else: + counts = {"attack": 1, "outlet": 1, "defense": n - 2} + + available = {slot: count for slot, count in counts.items() if slot in available_tactic_ids and count > 0} + spill = sum(count for slot, count in counts.items() if slot not in available_tactic_ids) + if spill: + target = next((slot for slot in ("defense", "attack", "outlet") if slot in available), None) + if target is None: + return {} + available[target] = available.get(target, 0) + spill + + partition: dict[str, frozenset[RobotId]] = {} + start = 0 + for slot in ("attack", "outlet", "defense"): + count = available.get(slot, 0) + if count > 0: + partition[slot] = frozenset(ordered[start : start + count]) + start += count + return partition + + +def build_split_outlet_kernel_strategy(outfield_robot_ids: tuple[int, ...]): + """5-robot (or fewer) split-outlet `Strategy` factory. + + Wires `LeadAndSupportTactic` ("attack"), `OutletTactic` ("outlet") and + `ShadowAndMarkTactic` ("defense") as concurrently active slots, split by + `_split_outlet_picker`. + + Returns a `build_kernel_strategy(motion_controller)` callable suitable for + `AbstractStrategy`'s constructor argument of the same name. + """ + + def _build(motion_controller: MotionController) -> KernelSchedulerStrategy: + ctx = TickContext(motion_controller=motion_controller) + return KernelSchedulerStrategy( + tactics={ + "attack": LeadAndSupportTactic(), + "outlet": OutletTactic(), + "defense": ShadowAndMarkTactic(), + }, + partitioner=_split_outlet_picker, + outfield_robot_ids=outfield_robot_ids, + ctx=ctx, + ) + + return _build diff --git a/utama_core/strategy/split_outlet_wall.py b/utama_core/strategy/split_outlet_wall.py new file mode 100644 index 00000000..cdd7e3e0 --- /dev/null +++ b/utama_core/strategy/split_outlet_wall.py @@ -0,0 +1,312 @@ +"""The `split_outlet_wall` kernel strategy: `split_outlet`'s slots plus `split_wall`'s free-kick wall. + +Combines the two best ideas of the 2026-10-11 search. Copied, not imported, from the +strategy modules (strategy modules do not import each other); keep the copies identical: + +- `_split_outlet_picker` and the tactic slots (attack `LeadAndSupportTactic`, outlet + `OutletTactic`, defense `ShadowAndMarkTactic`) are copied from `split_outlet.py`. +- The wall geometry (`WALL_*`, `MARK_GOAL_SIDE_M`, `LEFTOVER_SPACING_M`, `_unit`, + `_spread`, `wall_points`, `marker_point`, `leftover_points`, `_legal_target`, + `_own_goal_centre`, `opponent_free_kick_targets`) and the referee override + (`_restart_override`) are copied from `split_wall.py`. + +Design point: at the OPPONENT's direct free kick the wall override takes over every +outfield robot, the outlet robot included, so the outlet is one of the wall or markers +for that restart. Once the restart is released the picker takes over again as in +`split_outlet`. Our own direct free kick runs the built-in step unchanged. +""" + +from __future__ import annotations + +import math +from types import SimpleNamespace +from typing import Optional + +from utama_core.config.referee_constants import BALL_KEEP_OUT_DISTANCE +from utama_core.custom_referee.actions import ( + DirectFreeOursStep, + _clear_to_legal_positions, +) +from utama_core.engine.context import TickContext +from utama_core.engine.referee_override import RefereeActionOverride +from utama_core.engine.strategy import Strategy as KernelSchedulerStrategy +from utama_core.engine.tactic import RobotId +from utama_core.entities.data.object import TeamType +from utama_core.entities.data.vector import Vector2D +from utama_core.entities.game import Game +from utama_core.entities.referee.referee_command import RefereeCommand +from utama_core.motion_planning.src.common.motion_controller import MotionController +from utama_core.shared.pass_and_score_geometry import ( + clamp_outside_own_defense_area, + clamp_to_field, +) +from utama_core.skills.src.utils.move_utils import move +from utama_core.strategy.pickers import carrier_first +from utama_core.tactics.lead_and_support import LeadAndSupportTactic +from utama_core.tactics.outlet import OutletTactic +from utama_core.tactics.shadow_and_mark import ShadowAndMarkTactic + +# --------------------------------------------------------------------------- +# Possession split with an outlet slot (copied from split_outlet.py). +# --------------------------------------------------------------------------- + + +def _split_outlet_picker( + game: Game, + free_robots: frozenset[RobotId], + prev_partition: Optional[dict[str, frozenset[RobotId]]], + available_tactic_ids: frozenset[str], +) -> dict[str, frozenset[RobotId]]: + """`split_shape`'s split, with an outlet slot taken from the defence when we lack the ball edge. + + Counts (n = robots): with the ball edge, or with fewer than 3 robots, the split is + `split_shape`'s exactly and the outlet slot is never used. Otherwise attack 1, + outlet 1, defence n - 2. A slot missing from `available_tactic_ids` hands its + robots to defence, then attack, then outlet. + + Only emits a key for a slot it assigns robots to, never a zero-robot entry (see + `split_shape`'s picker for why). + """ + ordered = carrier_first(game, free_robots) + if not ordered: + return {} + n = len(ordered) + + _friendly_closest, friendly_dist = game.proximity_lookup.closest_to_ball(team_type_filter=TeamType.FRIENDLY) + _enemy_closest, enemy_dist = game.proximity_lookup.closest_to_ball(team_type_filter=TeamType.ENEMY) + friendly_has_ball_edge = friendly_dist < enemy_dist + + if friendly_has_ball_edge or n < 3: + attack_count = (n + 1) // 2 + 1 if friendly_has_ball_edge else n // 2 - 1 + attack_count = max(0, min(n, attack_count)) + counts = {"attack": attack_count, "outlet": 0, "defense": n - attack_count} + else: + counts = {"attack": 1, "outlet": 1, "defense": n - 2} + + available = {slot: count for slot, count in counts.items() if slot in available_tactic_ids and count > 0} + spill = sum(count for slot, count in counts.items() if slot not in available_tactic_ids) + if spill: + target = next((slot for slot in ("defense", "attack", "outlet") if slot in available), None) + if target is None: + return {} + available[target] = available.get(target, 0) + spill + + partition: dict[str, frozenset[RobotId]] = {} + start = 0 + for slot in ("attack", "outlet", "defense"): + count = available.get(slot, 0) + if count > 0: + partition[slot] = frozenset(ordered[start : start + count]) + start += count + return partition + + +# --------------------------------------------------------------------------- +# Opponent direct free kick: wall + marking geometry (copied from split_wall.py). +# --------------------------------------------------------------------------- + +# Wall sits 0.1 m beyond the built-in keep-out radius (0.8 m), well clear of the 0.5 m +# foul line, and still close enough to block a dribble straight at goal. +WALL_DISTANCE_M = BALL_KEEP_OUT_DISTANCE + 0.1 +WALL_SPACING_M = 0.2 +MARK_GOAL_SIDE_M = 0.5 +LEFTOVER_SPACING_M = 0.4 +# Every restart target stays at least this far from the ball (see `_legal_target`). +TARGET_KEEP_OUT_M = BALL_KEEP_OUT_DISTANCE +_RING_STEPS = 360 + + +def _own_goal_centre(game: Game) -> Vector2D: + return Vector2D(float(game.field.my_goal_line[0][0]), 0.0) + + +def _unit(start: Vector2D, end: Vector2D) -> tuple[float, float]: + dx, dy = end.x - start.x, end.y - start.y + length = math.hypot(dx, dy) + if length < 1e-9: + return 1.0, 0.0 + return dx / length, dy / length + + +def _spread(count: int, spacing: float) -> list[float]: + """Offsets centred on zero, ascending, `spacing` apart.""" + return [(i - (count - 1) / 2.0) * spacing for i in range(count)] + + +def wall_points(ball: Vector2D, goal: Vector2D, count: int) -> list[Vector2D]: + """`count` points side by side, WALL_DISTANCE_M from the ball on the ball->goal line. + + Ordered by ascending offset along the perpendicular to that line. + """ + ux, uy = _unit(ball, goal) + cx, cy = ball.x + ux * WALL_DISTANCE_M, ball.y + uy * WALL_DISTANCE_M + px, py = -uy, ux + return [Vector2D(cx + px * off, cy + py * off) for off in _spread(count, WALL_SPACING_M)] + + +def marker_point(man: Vector2D, goal: Vector2D) -> Vector2D: + """MARK_GOAL_SIDE_M from the marked enemy, on the enemy->our-goal line.""" + ux, uy = _unit(man, goal) + return Vector2D(man.x + ux * MARK_GOAL_SIDE_M, man.y + uy * MARK_GOAL_SIDE_M) + + +def leftover_points(ball: Vector2D, goal: Vector2D, front_x: float, count: int) -> list[Vector2D]: + """`count` points on the defense-area front edge where the ball->goal line crosses it. + + Spread along the edge (perpendicular to the line) LEFTOVER_SPACING_M apart. + """ + dx = goal.x - ball.x + if abs(dx) < 1e-9: + base = ball + else: + t = min(1.0, max(0.0, (front_x - ball.x) / dx)) + base = Vector2D(ball.x + t * dx, ball.y + t * (goal.y - ball.y)) + ux, uy = _unit(ball, goal) + px, py = -uy, ux + return [Vector2D(base.x + px * off, base.y + py * off) for off in _spread(count, LEFTOVER_SPACING_M)] + + +def _is_legal_spot(game: Game, point: Vector2D) -> bool: + """True when `point` is already inside the field and outside our defense area.""" + return clamp_outside_own_defense_area(game, clamp_to_field(point, game)).distance_to(point) < 1e-9 + + +def _legal_target(game: Game, point: Vector2D, ball: Vector2D, taken: tuple[Vector2D, ...] = ()) -> Vector2D: + """Inside the field, outside our defense area (DefenseAreaRule), and TARGET_KEEP_OUT_M from the ball. + + The box clamp can pull a point toward the ball. With the ball near the box front edge the + wall (0.9 m out on the goal line) lands behind the edge and is clamped to ~0.43 m from the + ball, inside the 0.5 m keep-out foul radius. So a target still inside the keep-out circle + moves to the nearest legal spot on that circle that no earlier target in `taken` claims + (WALL_SPACING_M apart). Several targets crowd the same stretch of box edge, so sliding + them all onto one line would stack robots on one spot. + """ + legal = clamp_outside_own_defense_area(game, clamp_to_field(point, game)) + if legal.distance_to(ball) >= TARGET_KEEP_OUT_M: + return legal + best, best_d = legal, math.inf + for step in range(_RING_STEPS): + angle = 2.0 * math.pi * step / _RING_STEPS + spot = Vector2D(ball.x + TARGET_KEEP_OUT_M * math.cos(angle), ball.y + TARGET_KEEP_OUT_M * math.sin(angle)) + if not _is_legal_spot(game, spot) or any(spot.distance_to(t) < WALL_SPACING_M for t in taken): + continue + d = spot.distance_to(legal) + if d < best_d: + best, best_d = spot, d + return best + + +def opponent_free_kick_targets(game: Game, outfield_ids) -> dict[RobotId, tuple[Vector2D, float]]: + """Target position and orientation for each present outfield robot at an opponent direct free kick. + + Two robots nearest the ball form the wall. The kicker is the enemy nearest the ball; + the other enemies, nearest our goal first, each get one marker (nearest free robot to + its mark point). Robots left over go to the defense-area edge on the ball->goal line. + Requires `game.ball`. + """ + ball = game.ball.p.to_2d() + goal = _own_goal_centre(game) + robots = [rid for rid in outfield_ids if rid in game.friendly_robots] + if not robots: + return {} + + by_ball = sorted(robots, key=lambda rid: game.friendly_robots[rid].p.distance_to(ball)) + wall_ids, free = by_ball[:2], by_ball[2:] + targets: dict[RobotId, tuple[Vector2D, float]] = {} + + # Wall: robots ordered by their side of the ball so the two do not cross. + px, py = -_unit(ball, goal)[1], _unit(ball, goal)[0] + wall_ids = sorted( + wall_ids, + key=lambda rid: (game.friendly_robots[rid].p.x - ball.x) * px + (game.friendly_robots[rid].p.y - ball.y) * py, + ) + for rid, point in zip(wall_ids, wall_points(ball, goal, len(wall_ids))): + legal = _legal_target(game, point, ball, tuple(p for p, _ in targets.values())) + targets[rid] = (legal, legal.angle_to(ball)) + + enemies = game.enemy_robots + if enemies: + kicker = min(enemies, key=lambda eid: enemies[eid].p.distance_to(ball)) + markable = sorted( + (eid for eid in enemies if eid != kicker), + key=lambda eid: enemies[eid].p.distance_to(goal), + ) + for eid in markable: + if not free: + break + man = enemies[eid].p + mark = _legal_target(game, marker_point(man, goal), ball, tuple(p for p, _ in targets.values())) + rid = min(free, key=lambda r: game.friendly_robots[r].p.distance_to(mark)) + free.remove(rid) + targets[rid] = (mark, mark.angle_to(man)) + + front_x = float(game.field.my_defense_area[1][0]) + for rid, point in zip(free, leftover_points(ball, goal, front_x, len(free))): + legal = _legal_target(game, point, ball, tuple(p for p, _ in targets.values())) + targets[rid] = (legal, legal.angle_to(ball)) + + return targets + + +def _restart_override(command_is_yellow: bool, outfield_ids, own_step: DirectFreeOursStep) -> RefereeActionOverride: + """Override for the raw command `DIRECT_FREE_{YELLOW,BLUE}`, which fires for both teams' kicks. + + The lookup is by raw colour, so this callable decides by `game.my_team_is_yellow` + which team the kick belongs to. Our own kick runs the built-in `DirectFreeOursStep` + unchanged. The opponent's kick gets the wall for outfield robots and the built-in + keep-out clearance for the goalkeeper. + """ + + def override(game: Game, motion_controller: MotionController): + shim = SimpleNamespace(game=game, motion_controller=motion_controller, cmd_map={}) + if game.my_team_is_yellow == command_is_yellow: + own_step.blackboard = shim + own_step.update() + return dict(shim.cmd_map) + + if game.ball is None: + _clear_to_legal_positions(shim, ball_keep_dist=BALL_KEEP_OUT_DISTANCE) + return dict(shim.cmd_map) + + for rid, (target, oren) in opponent_free_kick_targets(game, outfield_ids).items(): + shim.cmd_map[rid] = move(game, motion_controller, rid, target, oren) + _clear_to_legal_positions( + shim, + ball_keep_dist=BALL_KEEP_OUT_DISTANCE, + exempt_robot_ids=set(outfield_ids), + ) + return dict(shim.cmd_map) + + return override + + +def build_split_outlet_wall_kernel_strategy(outfield_robot_ids: tuple[int, ...]): + """5-robot (or fewer) split-outlet-wall `Strategy` factory. + + `split_outlet`'s tactics and picker, plus `split_wall`'s wall-and-mark referee + override at the opponent's direct free kick (see module docstring). + + Returns a `build_kernel_strategy(motion_controller)` callable suitable for + `AbstractStrategy`'s constructor argument of the same name. + """ + + def _build(motion_controller: MotionController) -> KernelSchedulerStrategy: + ctx = TickContext(motion_controller=motion_controller) + own_step = DirectFreeOursStep() + overrides = { + RefereeCommand.DIRECT_FREE_YELLOW: _restart_override(True, outfield_robot_ids, own_step), + RefereeCommand.DIRECT_FREE_BLUE: _restart_override(False, outfield_robot_ids, own_step), + } + return KernelSchedulerStrategy( + tactics={ + "attack": LeadAndSupportTactic(), + "outlet": OutletTactic(), + "defense": ShadowAndMarkTactic(), + }, + partitioner=_split_outlet_picker, + outfield_robot_ids=outfield_robot_ids, + ctx=ctx, + referee_overrides=overrides, + ) + + return _build diff --git a/utama_core/strategy/split_press.py b/utama_core/strategy/split_press.py new file mode 100644 index 00000000..3d4995e1 --- /dev/null +++ b/utama_core/strategy/split_press.py @@ -0,0 +1,101 @@ +"""The `split_press` kernel strategy: `build_split_press_kernel_strategy` and the pickers only it uses. + +Differs from `split_shape` in one place: without the ball edge, the lone chaser +(the leader of `LeadAndSupportTactic`, which just walked to the ball) is replaced +by a 2-robot `PressAndContainTactic` slot ("press") while an enemy is within +press range. Split_shape's robots got close to the ball but did not win it, so +the press pairs a presser with a marker on the likely receiver. With the ball +edge, the split is split_shape's unchanged. +""" + +from __future__ import annotations + +from typing import Optional + +from utama_core.engine.context import TickContext +from utama_core.engine.strategy import Strategy as KernelSchedulerStrategy +from utama_core.engine.tactic import RobotId +from utama_core.entities.data.object import TeamType +from utama_core.entities.game import Game +from utama_core.motion_planning.src.common.motion_controller import MotionController +from utama_core.strategy.pickers import carrier_first +from utama_core.tactics.lead_and_support import LeadAndSupportTactic +from utama_core.tactics.press_and_contain import PressAndContainTactic +from utama_core.tactics.shadow_and_mark import ShadowAndMarkTactic + +_PRESS_ROBOTS = 2 # one presser + one marker of the likely receiver + + +def _split_press_picker( + game: Game, + free_robots: frozenset[RobotId], + prev_partition: Optional[dict[str, frozenset[RobotId]]], + available_tactic_ids: frozenset[str], +) -> dict[str, frozenset[RobotId]]: + """Split_shape's possession split, with a 2-robot press in place of the lone chaser. + + Without the ball edge and with "press" applicable (an enemy within press range + of the ball), the first two robots in carrier_first order press and the rest + defend. Otherwise this is `_possession_split_picker`'s split unchanged. + + Only emits keys for slots it assigns robots to, never a zero-robot entry. + """ + ordered = carrier_first(game, free_robots) + if not ordered: + return {} + + _friendly_closest, friendly_dist = game.proximity_lookup.closest_to_ball(team_type_filter=TeamType.FRIENDLY) + _enemy_closest, enemy_dist = game.proximity_lookup.closest_to_ball(team_type_filter=TeamType.ENEMY) + friendly_has_ball_edge = friendly_dist < enemy_dist + + if "press" in available_tactic_ids and not friendly_has_ball_edge: + partition = {"press": frozenset(ordered[:_PRESS_ROBOTS])} + rest = ordered[_PRESS_ROBOTS:] + if rest: + if "defense" in available_tactic_ids: + partition["defense"] = frozenset(rest) + elif "attack" in available_tactic_ids: + partition["attack"] = frozenset(rest) + return partition + + if "attack" not in available_tactic_ids: + return {"defense": frozenset(ordered)} if "defense" in available_tactic_ids else {} + if "defense" not in available_tactic_ids: + return {"attack": frozenset(ordered)} + + attack_count = (len(ordered) + 1) // 2 + 1 if friendly_has_ball_edge else len(ordered) // 2 - 1 + attack_count = max(0, min(len(ordered), attack_count)) + + partition = {} + if attack_count > 0: + partition["attack"] = frozenset(ordered[:attack_count]) + if attack_count < len(ordered): + partition["defense"] = frozenset(ordered[attack_count:]) + return partition + + +def build_split_press_kernel_strategy(outfield_robot_ids: tuple[int, ...]): + """5-robot (or fewer) split-press `Strategy` factory. + + Wires `LeadAndSupportTactic` ("attack"), `ShadowAndMarkTactic` ("defense") and + `PressAndContainTactic` ("press", applicable only within press range of the + ball), split by `_split_press_picker`. + + Returns a `build_kernel_strategy(motion_controller)` callable suitable for + `AbstractStrategy`'s constructor argument of the same name. + """ + + def _build(motion_controller: MotionController) -> KernelSchedulerStrategy: + ctx = TickContext(motion_controller=motion_controller) + return KernelSchedulerStrategy( + tactics={ + "attack": LeadAndSupportTactic(), + "defense": ShadowAndMarkTactic(), + "press": PressAndContainTactic(), + }, + partitioner=_split_press_picker, + outfield_robot_ids=outfield_robot_ids, + ctx=ctx, + ) + + return _build diff --git a/utama_core/strategy/split_sticky.py b/utama_core/strategy/split_sticky.py new file mode 100644 index 00000000..25537fd5 --- /dev/null +++ b/utama_core/strategy/split_sticky.py @@ -0,0 +1,87 @@ +"""The `split_sticky` kernel strategy: `build_split_sticky_kernel_strategy` and the picker only it uses. + +Same two slots and split sizes as `split_shape` (`LeadAndSupportTactic` "attack", +`ShadowAndMarkTactic` "defense"). The one change is a sticky possession edge: +`split_shape` re-reads which side is closer to the ball every tick, so its +posture flickers in contested balls. Here, holding the attacking posture +survives a near-tie and is only given up on a clear loss; switching into it +needs a clear gain. +""" + +from __future__ import annotations + +from typing import Optional + +from utama_core.engine.context import TickContext +from utama_core.engine.strategy import Strategy as KernelSchedulerStrategy +from utama_core.engine.tactic import RobotId +from utama_core.entities.game import Game +from utama_core.motion_planning.src.common.motion_controller import MotionController +from utama_core.strategy.pickers import carrier_first, friendly_closer_to_ball +from utama_core.tactics.lead_and_support import LeadAndSupportTactic +from utama_core.tactics.shadow_and_mark import ShadowAndMarkTactic + + +def _sticky_split_picker( + game: Game, + free_robots: frozenset[RobotId], + prev_partition: Optional[dict[str, frozenset[RobotId]]], + available_tactic_ids: frozenset[str], +) -> dict[str, frozenset[RobotId]]: + """Whichever side is closer to the ball decides posture, with hysteresis on the edge. + + "Holding attack" means last tick's attack slot had more robots than defense. + While holding it, only a clear loss (`friendly_closer_to_ball` is False) + gives it up; a near-tie (None) keeps it. Not holding it, only a clear gain + (True) takes it. Split sizes are `split_shape`'s: attack (n+1)//2+1 when + attacking, n//2-1 otherwise. + + A slot missing from `available_tactic_ids` is pinned by a commitment, and + its share goes to the other slot. Only emits a key for a tactic it is + actually assigning robots to, never a zero-robot entry. + """ + ordered = carrier_first(game, free_robots) + if not ordered: + return {} + + prev_partition = prev_partition or {} + holding_attack = len(prev_partition.get("attack", ())) > len(prev_partition.get("defense", ())) + edge = friendly_closer_to_ball(game) + attacking = edge is not False if holding_attack else edge is True + + if "attack" not in available_tactic_ids: + return {"defense": frozenset(ordered)} if "defense" in available_tactic_ids else {} + if "defense" not in available_tactic_ids: + return {"attack": frozenset(ordered)} + + attack_count = (len(ordered) + 1) // 2 + 1 if attacking else len(ordered) // 2 - 1 + attack_count = max(0, min(len(ordered), attack_count)) + + partition = {} + if attack_count > 0: + partition["attack"] = frozenset(ordered[:attack_count]) + if attack_count < len(ordered): + partition["defense"] = frozenset(ordered[attack_count:]) + return partition + + +def build_split_sticky_kernel_strategy(outfield_robot_ids: tuple[int, ...]): + """5-robot (or fewer) sticky split-shape `Strategy` factory. + + Wires `LeadAndSupportTactic` ("attack") and `ShadowAndMarkTactic` + ("defense") as two concurrently active slots, split by `_sticky_split_picker`. + + Returns a `build_kernel_strategy(motion_controller)` callable suitable for + `AbstractStrategy`'s constructor argument of the same name. + """ + + def _build(motion_controller: MotionController) -> KernelSchedulerStrategy: + ctx = TickContext(motion_controller=motion_controller) + return KernelSchedulerStrategy( + tactics={"attack": LeadAndSupportTactic(), "defense": ShadowAndMarkTactic()}, + partitioner=_sticky_split_picker, + outfield_robot_ids=outfield_robot_ids, + ctx=ctx, + ) + + return _build diff --git a/utama_core/strategy/split_wall.py b/utama_core/strategy/split_wall.py new file mode 100644 index 00000000..32869f32 --- /dev/null +++ b/utama_core/strategy/split_wall.py @@ -0,0 +1,299 @@ +"""The `split_wall` kernel strategy: `build_split_wall_kernel_strategy` and the pickers only it uses. + +Same tactics and possession split as `split_shape`, with one change at the OPPONENT's +direct free kick. split_shape conceded 9 of its 26 goals right after one: the built-in +restart step only pushes our robots out of the 0.8 m ball keep-out and leaves the rest +2.5-3.5 m back, so the kicker dribbles in. Here the two outfield robots nearest the ball +form a wall between the ball and our goal, and the others mark the enemies nearest our +goal. Our own free kick and the goalkeeper's restart positioning are unchanged. +""" + +from __future__ import annotations + +import math +from types import SimpleNamespace +from typing import Optional + +from utama_core.config.referee_constants import BALL_KEEP_OUT_DISTANCE +from utama_core.custom_referee.actions import ( + DirectFreeOursStep, + _clear_to_legal_positions, +) +from utama_core.engine.context import TickContext +from utama_core.engine.referee_override import RefereeActionOverride +from utama_core.engine.strategy import Strategy as KernelSchedulerStrategy +from utama_core.engine.tactic import RobotId +from utama_core.entities.data.object import TeamType +from utama_core.entities.data.vector import Vector2D +from utama_core.entities.game import Game +from utama_core.entities.referee.referee_command import RefereeCommand +from utama_core.motion_planning.src.common.motion_controller import MotionController +from utama_core.shared.pass_and_score_geometry import ( + clamp_outside_own_defense_area, + clamp_to_field, +) +from utama_core.skills.src.utils.move_utils import move +from utama_core.strategy.pickers import carrier_first +from utama_core.tactics.lead_and_support import LeadAndSupportTactic +from utama_core.tactics.shadow_and_mark import ShadowAndMarkTactic + +# Wall sits 0.1 m beyond the built-in keep-out radius (0.8 m), well clear of the 0.5 m +# foul line, and still close enough to block a dribble straight at goal. +WALL_DISTANCE_M = BALL_KEEP_OUT_DISTANCE + 0.1 +WALL_SPACING_M = 0.2 +MARK_GOAL_SIDE_M = 0.5 +LEFTOVER_SPACING_M = 0.4 +# Every restart target stays at least this far from the ball (see `_legal_target`). +TARGET_KEEP_OUT_M = BALL_KEEP_OUT_DISTANCE +_RING_STEPS = 360 + + +def _possession_split_picker( + game: Game, + free_robots: frozenset[RobotId], + prev_partition: Optional[dict[str, frozenset[RobotId]]], + available_tactic_ids: frozenset[str], +) -> dict[str, frozenset[RobotId]]: + """Whichever side is closer to the ball decides posture; the split is fixed once decided. + + Neither `LeadAndSupportTactic` nor `ShadowAndMarkTactic` overrides + `applicable()`, so a slot is missing from `available_tactic_ids` only + while it's pinned by a commitment — its share then goes to the other slot. + + Deliberately the simplest rule that gives the split-shape scheduler + something real to react to, not a scored/tunable allocator (see the + design doc's stance against bid/fitness-scoring machinery) — one signal + (which team is closer to the ball), two fixed splits. Ties and an + unreadable proximity lookup default to the more conservative + (defense-heavy) split. + + Only emits a key for a tactic it is actually assigning free robots to — + never a zero-robot entry, since `Strategy` treats every key in a + `Partitioner`'s return value as "I am claiming this tactic id right now," + and a present-but-empty entry for a tactic committed and pinned + elsewhere would collide with that pin. + """ + ordered = carrier_first(game, free_robots) + if not ordered: + return {} + + _friendly_closest, friendly_dist = game.proximity_lookup.closest_to_ball(team_type_filter=TeamType.FRIENDLY) + _enemy_closest, enemy_dist = game.proximity_lookup.closest_to_ball(team_type_filter=TeamType.ENEMY) + friendly_has_ball_edge = friendly_dist < enemy_dist + + if "attack" not in available_tactic_ids: + return {"defense": frozenset(ordered)} if "defense" in available_tactic_ids else {} + if "defense" not in available_tactic_ids: + return {"attack": frozenset(ordered)} + + attack_count = (len(ordered) + 1) // 2 + 1 if friendly_has_ball_edge else len(ordered) // 2 - 1 + attack_count = max(0, min(len(ordered), attack_count)) + + partition = {} + if attack_count > 0: + partition["attack"] = frozenset(ordered[:attack_count]) + if attack_count < len(ordered): + partition["defense"] = frozenset(ordered[attack_count:]) + return partition + + +# --------------------------------------------------------------------------- +# Opponent direct free kick: wall + marking geometry (pure, unit-tested). +# --------------------------------------------------------------------------- + + +def _own_goal_centre(game: Game) -> Vector2D: + return Vector2D(float(game.field.my_goal_line[0][0]), 0.0) + + +def _unit(start: Vector2D, end: Vector2D) -> tuple[float, float]: + dx, dy = end.x - start.x, end.y - start.y + length = math.hypot(dx, dy) + if length < 1e-9: + return 1.0, 0.0 + return dx / length, dy / length + + +def _spread(count: int, spacing: float) -> list[float]: + """Offsets centred on zero, ascending, `spacing` apart.""" + return [(i - (count - 1) / 2.0) * spacing for i in range(count)] + + +def wall_points(ball: Vector2D, goal: Vector2D, count: int) -> list[Vector2D]: + """`count` points side by side, WALL_DISTANCE_M from the ball on the ball->goal line. + + Ordered by ascending offset along the perpendicular to that line. + """ + ux, uy = _unit(ball, goal) + cx, cy = ball.x + ux * WALL_DISTANCE_M, ball.y + uy * WALL_DISTANCE_M + px, py = -uy, ux + return [Vector2D(cx + px * off, cy + py * off) for off in _spread(count, WALL_SPACING_M)] + + +def marker_point(man: Vector2D, goal: Vector2D) -> Vector2D: + """MARK_GOAL_SIDE_M from the marked enemy, on the enemy->our-goal line.""" + ux, uy = _unit(man, goal) + return Vector2D(man.x + ux * MARK_GOAL_SIDE_M, man.y + uy * MARK_GOAL_SIDE_M) + + +def leftover_points(ball: Vector2D, goal: Vector2D, front_x: float, count: int) -> list[Vector2D]: + """`count` points on the defense-area front edge where the ball->goal line crosses it. + + Spread along the edge (perpendicular to the line) LEFTOVER_SPACING_M apart. + """ + dx = goal.x - ball.x + if abs(dx) < 1e-9: + base = ball + else: + t = min(1.0, max(0.0, (front_x - ball.x) / dx)) + base = Vector2D(ball.x + t * dx, ball.y + t * (goal.y - ball.y)) + ux, uy = _unit(ball, goal) + px, py = -uy, ux + return [Vector2D(base.x + px * off, base.y + py * off) for off in _spread(count, LEFTOVER_SPACING_M)] + + +def _is_legal_spot(game: Game, point: Vector2D) -> bool: + """True when `point` is already inside the field and outside our defense area.""" + return clamp_outside_own_defense_area(game, clamp_to_field(point, game)).distance_to(point) < 1e-9 + + +def _legal_target(game: Game, point: Vector2D, ball: Vector2D, taken: tuple[Vector2D, ...] = ()) -> Vector2D: + """Inside the field, outside our defense area (DefenseAreaRule), and TARGET_KEEP_OUT_M from the ball. + + The box clamp can pull a point toward the ball. With the ball near the box front edge the + wall (0.9 m out on the goal line) lands behind the edge and is clamped to ~0.43 m from the + ball, inside the 0.5 m keep-out foul radius. So a target still inside the keep-out circle + moves to the nearest legal spot on that circle that no earlier target in `taken` claims + (WALL_SPACING_M apart). Several targets crowd the same stretch of box edge, so sliding + them all onto one line would stack robots on one spot. + """ + legal = clamp_outside_own_defense_area(game, clamp_to_field(point, game)) + if legal.distance_to(ball) >= TARGET_KEEP_OUT_M: + return legal + best, best_d = legal, math.inf + for step in range(_RING_STEPS): + angle = 2.0 * math.pi * step / _RING_STEPS + spot = Vector2D(ball.x + TARGET_KEEP_OUT_M * math.cos(angle), ball.y + TARGET_KEEP_OUT_M * math.sin(angle)) + if not _is_legal_spot(game, spot) or any(spot.distance_to(t) < WALL_SPACING_M for t in taken): + continue + d = spot.distance_to(legal) + if d < best_d: + best, best_d = spot, d + return best + + +def opponent_free_kick_targets(game: Game, outfield_ids) -> dict[RobotId, tuple[Vector2D, float]]: + """Target position and orientation for each present outfield robot at an opponent direct free kick. + + Two robots nearest the ball form the wall. The kicker is the enemy nearest the ball; + the other enemies, nearest our goal first, each get one marker (nearest free robot to + its mark point). Robots left over go to the defense-area edge on the ball->goal line. + Requires `game.ball`. + """ + ball = game.ball.p.to_2d() + goal = _own_goal_centre(game) + robots = [rid for rid in outfield_ids if rid in game.friendly_robots] + if not robots: + return {} + + by_ball = sorted(robots, key=lambda rid: game.friendly_robots[rid].p.distance_to(ball)) + wall_ids, free = by_ball[:2], by_ball[2:] + targets: dict[RobotId, tuple[Vector2D, float]] = {} + + # Wall: robots ordered by their side of the ball so the two do not cross. + px, py = -_unit(ball, goal)[1], _unit(ball, goal)[0] + wall_ids = sorted( + wall_ids, + key=lambda rid: (game.friendly_robots[rid].p.x - ball.x) * px + (game.friendly_robots[rid].p.y - ball.y) * py, + ) + for rid, point in zip(wall_ids, wall_points(ball, goal, len(wall_ids))): + legal = _legal_target(game, point, ball, tuple(p for p, _ in targets.values())) + targets[rid] = (legal, legal.angle_to(ball)) + + enemies = game.enemy_robots + if enemies: + kicker = min(enemies, key=lambda eid: enemies[eid].p.distance_to(ball)) + markable = sorted( + (eid for eid in enemies if eid != kicker), + key=lambda eid: enemies[eid].p.distance_to(goal), + ) + for eid in markable: + if not free: + break + man = enemies[eid].p + mark = _legal_target(game, marker_point(man, goal), ball, tuple(p for p, _ in targets.values())) + rid = min(free, key=lambda r: game.friendly_robots[r].p.distance_to(mark)) + free.remove(rid) + targets[rid] = (mark, mark.angle_to(man)) + + front_x = float(game.field.my_defense_area[1][0]) + for rid, point in zip(free, leftover_points(ball, goal, front_x, len(free))): + legal = _legal_target(game, point, ball, tuple(p for p, _ in targets.values())) + targets[rid] = (legal, legal.angle_to(ball)) + + return targets + + +# --------------------------------------------------------------------------- +# Referee override: wall at the opponent's direct free kick, built-in otherwise. +# --------------------------------------------------------------------------- + + +def _restart_override(command_is_yellow: bool, outfield_ids, own_step: DirectFreeOursStep) -> RefereeActionOverride: + """Override for the raw command `DIRECT_FREE_{YELLOW,BLUE}`, which fires for both teams' kicks. + + The lookup is by raw colour, so this callable decides by `game.my_team_is_yellow` + which team the kick belongs to. Our own kick runs the built-in `DirectFreeOursStep` + unchanged. The opponent's kick gets the wall for outfield robots and the built-in + keep-out clearance for the goalkeeper. + """ + + def override(game: Game, motion_controller: MotionController): + shim = SimpleNamespace(game=game, motion_controller=motion_controller, cmd_map={}) + if game.my_team_is_yellow == command_is_yellow: + own_step.blackboard = shim + own_step.update() + return dict(shim.cmd_map) + + if game.ball is None: + _clear_to_legal_positions(shim, ball_keep_dist=BALL_KEEP_OUT_DISTANCE) + return dict(shim.cmd_map) + + for rid, (target, oren) in opponent_free_kick_targets(game, outfield_ids).items(): + shim.cmd_map[rid] = move(game, motion_controller, rid, target, oren) + _clear_to_legal_positions( + shim, + ball_keep_dist=BALL_KEEP_OUT_DISTANCE, + exempt_robot_ids=set(outfield_ids), + ) + return dict(shim.cmd_map) + + return override + + +def build_split_wall_kernel_strategy(outfield_robot_ids: tuple[int, ...]): + """5-robot (or fewer) split-wall `Strategy` factory. + + `split_shape`'s tactics and picker, plus a wall-and-mark referee override at the + opponent's direct free kick (see module docstring). + + Returns a `build_kernel_strategy(motion_controller)` callable suitable for + `AbstractStrategy`'s constructor argument of the same name. + """ + + def _build(motion_controller: MotionController) -> KernelSchedulerStrategy: + ctx = TickContext(motion_controller=motion_controller) + own_step = DirectFreeOursStep() + overrides = { + RefereeCommand.DIRECT_FREE_YELLOW: _restart_override(True, outfield_robot_ids, own_step), + RefereeCommand.DIRECT_FREE_BLUE: _restart_override(False, outfield_robot_ids, own_step), + } + return KernelSchedulerStrategy( + tactics={"attack": LeadAndSupportTactic(), "defense": ShadowAndMarkTactic()}, + partitioner=_possession_split_picker, + outfield_robot_ids=outfield_robot_ids, + ctx=ctx, + referee_overrides=overrides, + ) + + return _build diff --git a/utama_core/tactics/decoy_and_overload.py b/utama_core/tactics/decoy_and_overload.py index 69a3b9a5..dcbd60ac 100644 --- a/utama_core/tactics/decoy_and_overload.py +++ b/utama_core/tactics/decoy_and_overload.py @@ -80,6 +80,13 @@ _LURE_MAX_TICKS = round(_LURE_MAX_TIME * CONTROL_FREQUENCY) _FINISH_TIMEOUT_TIME = 12.0 # seconds — same pass+shoot budget as pass_and_shoot._PHASE_TIMEOUT_TIME _FINISH_TIMEOUT_TICKS = round(_FINISH_TIMEOUT_TIME * CONTROL_FREQUENCY) +# Release the finish phase when the ball hasn't moved for this long. 3 s is well under the +# referee's 10 s no-progress STOP, so the tactic gives up before the match freezes. A decoy +# body-to-body with a marker (tournament_20261011_205245, ~510.8 s) sat motionless in "finish" +# for 10 s while the commitment held until the 12 s budget above. +_FINISH_NO_PROGRESS_TIME = 3.0 # seconds +_FINISH_NO_PROGRESS_TICKS = round(_FINISH_NO_PROGRESS_TIME * CONTROL_FREQUENCY) +_FINISH_PROGRESS_DISTANCE = 0.05 # metres — same stillness tolerance as the stall watchdog (_STALL_BALL_STILL_TOL_M) _LURE_TOUCHLINE_MARGIN = 0.5 # metres in from the touchline — how close the decoy's lure run goes _OVERLOAD_STANDOFF = 0.4 # metres — how far past the marker's original shadow the overloader sits _LOOSE_BALL_SPEED = 0.3 # m/s — matches ball_is_loose's own threshold; see _teammate_already_has_ball @@ -232,6 +239,8 @@ class DecoyOverloadMem: prev_best_shot_y: Optional[float] = None # feeds _score_goal's switch-margin hysteresis; see _pass_and_score.py carry_origin: Optional[Vector2D] = None # see shared carry_origin; the lure ends before an excessive-dribbling foul enemy_ball_ticks: int = 0 # consecutive ticks an enemy robot has held the ball; see _ENEMY_BALL_RELEASE_TICKS + finish_anchor: Optional[Vector2D] = None # ball position the finish-phase no-progress check measures from + finish_still_ticks: int = 0 # consecutive finish ticks the ball has stayed within _FINISH_PROGRESS_DISTANCE def _support_hold_point(game: Game, robot_id: int, index: int) -> Vector2D: @@ -449,6 +458,17 @@ def tick( mem.finish_ticks += 1 if mem.finish_ticks > _FINISH_TIMEOUT_TICKS or _enemy_held_ball_too_long(game, mem): return {}, DecoyOverloadMem() + # No-progress release: the ball hasn't moved for _FINISH_NO_PROGRESS_TICKS, so the + # pass/shot isn't happening (e.g. the decoy is held body-to-body with a marker). + # Release now rather than holding the commitment to the 12 s budget above. + ball_xy = game.ball.p.to_2d() + if mem.finish_anchor is None or ball_xy.distance_to(mem.finish_anchor) > _FINISH_PROGRESS_DISTANCE: + mem.finish_anchor = ball_xy + mem.finish_still_ticks = 0 + else: + mem.finish_still_ticks += 1 + if mem.finish_still_ticks > _FINISH_NO_PROGRESS_TICKS: + return {}, DecoyOverloadMem() if has_ball(game, mem.decoy_id, visual=True) and _decoy_shot_open(game, mem.decoy_id): shot_cmd, scored, mem.prev_best_shot_y = _score_goal(game, ctx, mem.decoy_id, mem.prev_best_shot_y) diff --git a/utama_core/tactics/lead_and_support.py b/utama_core/tactics/lead_and_support.py index a3b94f34..f03730de 100644 --- a/utama_core/tactics/lead_and_support.py +++ b/utama_core/tactics/lead_and_support.py @@ -50,6 +50,9 @@ _MIN_SUPPORT_SEPARATION = 0.9 # metres — supports must not crowd each other or the leader _SUPPORT_FORWARD_BIAS = 0.35 # weight favouring support points closer to the enemy goal +_SHOT_SWITCH_MARGIN = ( + 0.15 # metres of extra clearance a new shot gap must beat the previous one by (as _pass_and_score.py) +) def _candidate_support_points(game: Game, count: int) -> list[Vector2D]: @@ -143,6 +146,7 @@ def choose_leader(game: Game, robot_ids: tuple[int, ...]) -> int: @dataclass class LeadAndSupportMem: leader_id: Optional[int] = None + shot_y: Optional[float] = None # feeds find_best_shot's switch-margin hysteresis class LeadAndSupportTactic(BaseTactic[LeadAndSupportMem]): @@ -173,8 +177,11 @@ def is_committed(self, game: Game, mem: LeadAndSupportMem) -> bool: def tick( self, game: Game, ctx: TickContext, robot_ids: tuple[RobotId, ...], mem: LeadAndSupportMem ) -> tuple[dict[RobotId, RobotCommand], LeadAndSupportMem]: + previous_leader_id = mem.leader_id if mem.leader_id is None or mem.leader_id not in robot_ids or not self.is_committed(game, mem): mem.leader_id = choose_leader(game, robot_ids) + if mem.leader_id != previous_leader_id: + mem.shot_y = None leader_id = mem.leader_id support_ids = tuple(rid for rid in robot_ids if rid != leader_id) @@ -184,6 +191,7 @@ def tick( leader_has_ball = has_ball(game, leader_id) if not leader_has_ball: + mem.shot_y = None # go_to_ball itself holds outside the enemy's box instead of # chasing an unreachable ball when the keeper is holding it # there during active play — see that function's docstring. @@ -192,7 +200,16 @@ def tick( ) else: goal_x, goal_y1, goal_y2 = enemy_goal_line(game) - best_shot_y, gap = find_best_shot(leader_pos, list(game.enemy_robots.values()), goal_x, goal_y1, goal_y2) + best_shot_y, gap = find_best_shot( + leader_pos, + list(game.enemy_robots.values()), + goal_x, + goal_y1, + goal_y2, + prev_best_shot_y=mem.shot_y, + switch_margin=_SHOT_SWITCH_MARGIN, + ) + mem.shot_y = best_shot_y if best_shot_y is None: # See `no_shot_reposition_target`'s docstring: freezing here # never resolves against a stationary blocker. diff --git a/utama_core/tactics/outlet.py b/utama_core/tactics/outlet.py new file mode 100644 index 00000000..5fee0096 --- /dev/null +++ b/utama_core/tactics/outlet.py @@ -0,0 +1,78 @@ +"""Outlet tactic: robots held in the attacking half on the weak side, ready for a counter-attack. + +New tactic, not a copy of an existing one. Its robots never chase the ball and never +commit: each tick they move to a holding point just past halfway on the enemy side, +on the flank away from the ball, facing the ball. A turnover then has a receiver +ahead of the defensive line instead of only the attack slot's own robots. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from utama_core.engine.context import TickContext +from utama_core.engine.tactic import BaseTactic, RobotId, TacticTag +from utama_core.entities.data.command import RobotCommand +from utama_core.entities.data.vector import Vector2D +from utama_core.entities.game import Game +from utama_core.shared.pass_and_score_geometry import ( + clamp_outside_enemy_defense_area, + enemy_goal_line, +) +from utama_core.skills.src.utils.move_utils import move + +_HOLD_X_FRACTION = 0.25 # of the enemy goal's x: just past the halfway line on the enemy side +_BALL_Y_FRACTION = -0.6 # the holding line sits on the side away from the ball +_EDGE_MARGIN = 0.5 # metres kept clear of the touchlines +_SPREAD = 1.5 # metres between robots if the slot ever holds more than one + + +def holding_points(game: Game, count: int) -> list[Vector2D]: + """`count` holding points on the weak side of the attacking half, spread along y. + + The x comes from the enemy goal's own x, so the same code serves both attack + directions. Points that would land in the enemy defense area are clamped out. + """ + goal_x, _, _ = enemy_goal_line(game) + ball = game.ball.p.to_2d() + limit = game.field.half_width - _EDGE_MARGIN + base_y = max(-limit, min(limit, _BALL_Y_FRACTION * ball.y)) + points = [] + for i in range(count): + y = max(-limit, min(limit, base_y + (i - (count - 1) / 2.0) * _SPREAD)) + points.append(clamp_outside_enemy_defense_area(game, Vector2D(_HOLD_X_FRACTION * goal_x, y))) + return points + + +@dataclass +class OutletMem: + """Stateless: the holding points are recomputed from the ball every tick.""" + + +class OutletTactic(BaseTactic[OutletMem]): + """Holds its robots on the weak side of the attacking half, facing the ball. + + Never committed, so the scheduler can always move its robots elsewhere. Uses + `move()` with an explicit orientation because `go_to_point()` always faces the ball. + """ + + tag = TacticTag.ATTACK + keeps_mem_on_robot_change = True + + def initial_mem(self) -> OutletMem: + return OutletMem() + + def tick( + self, game: Game, ctx: TickContext, robot_ids: tuple[RobotId, ...], mem: OutletMem + ) -> tuple[dict[RobotId, RobotCommand], OutletMem]: + ball = game.ball.p.to_2d() + commands: dict[RobotId, RobotCommand] = {} + for robot_id, point in zip(robot_ids, holding_points(game, len(robot_ids))): + commands[robot_id] = move( + game=game, + motion_controller=ctx.motion_controller, + robot_id=robot_id, + target_coords=point, + target_oren=point.angle_to(ball), + ) + return commands, mem diff --git a/utama_core/tests/engine/test_decoy_and_overload_tactic.py b/utama_core/tests/engine/test_decoy_and_overload_tactic.py index 62a1958c..e1bb9621 100644 --- a/utama_core/tests/engine/test_decoy_and_overload_tactic.py +++ b/utama_core/tests/engine/test_decoy_and_overload_tactic.py @@ -30,6 +30,7 @@ import pytest from utama_core.config.field_params import STANDARD_FIELD_DIMS +from utama_core.config.settings import CONTROL_FREQUENCY from utama_core.engine.context import TickContext from utama_core.entities.data.vector import Vector2D, Vector3D from utama_core.entities.game import Field, Game, GameHistory @@ -290,3 +291,49 @@ def test_tactic_releases_its_robots_once_the_enemy_has_held_the_ball_1s(held_tic for _ in range(held_ticks): _commands, mem = tactic.tick(game, ctx, (1, 3), mem) assert tactic.is_committed(game, mem) is not released + + +# --------------------------------------------------------------------------- +# No-progress release in the "finish" phase +# --------------------------------------------------------------------------- + + +def test_finish_phase_releases_when_decoy_is_stuck_on_the_ball_with_an_enemy_at_0_12m(): + """The decoy holds the ball body-to-body with an enemy marker 0.12 m away, and + the ball never moves. The planner then holds the decoy still, so nothing + progresses. Before the no-progress release, `is_committed()` stayed True until + the 12 s finish budget, longer than the referee's 10 s no-progress STOP + (tournament_20261011_205245, freeze ~510.8 s). The tactic must give up after + ~3 s of unchanged ball position: still committed at 2.9 s, released by 3.1 s.""" + friendly = {1: _robot(1, 0.0, 0.0, True, has_ball=True), 3: _robot(3, -1.5, 1.5, True)} + enemy = {4: _robot(4, 0.12, 0.0, False)} + zv = Vector3D(0, 0, 0) + frame = GameFrame( + ts=0.0, + my_team_is_yellow=True, + my_team_is_right=True, + friendly_robots=friendly, + enemy_robots=enemy, + ball=Ball(p=Vector3D(0.09, 0.0, 0.0), v=zv, a=zv), + ) + field = Field( + my_team_is_right=True, field_dims=STANDARD_FIELD_DIMS, field_bounds=STANDARD_FIELD_DIMS.full_field_bounds + ) + game = Game(past=GameHistory(max_history=20), current=frame, field=field) + tactic = DecoyOverloadTactic() + mem = DecoyOverloadMem( + decoy_id=1, overloader_id=3, marker_id=4, marker_start_y=0.0, phase="finish", carry_origin=Vector2D(0.0, 0.0) + ) + ctx = TickContext(motion_controller=_NullMotionController()) + for _ in range(round(2.9 * CONTROL_FREQUENCY)): + _commands, mem = tactic.tick(game, ctx, (1, 3), mem) + assert tactic.is_committed(game, mem) is True + # Stop at the first tick that releases. The released mem is fresh, so a later tick would + # re-enter the lure with the same robots and hide the release. + for _ in range(round(3.1 * CONTROL_FREQUENCY) - round(2.9 * CONTROL_FREQUENCY)): + _commands, mem = tactic.tick(game, ctx, (1, 3), mem) + if not tactic.is_committed(game, mem): + break + else: + pytest.fail("finish phase never released although the ball stayed still for 3.1 s") + assert mem.decoy_id is None diff --git a/utama_core/tests/engine/test_lead_and_support_tactic.py b/utama_core/tests/engine/test_lead_and_support_tactic.py index 5273a186..fd16ce0b 100644 --- a/utama_core/tests/engine/test_lead_and_support_tactic.py +++ b/utama_core/tests/engine/test_lead_and_support_tactic.py @@ -12,6 +12,7 @@ from __future__ import annotations +import math from unittest.mock import MagicMock import pytest @@ -20,15 +21,18 @@ from utama_core.engine.context import TickContext from utama_core.entities.data.vector import Vector2D, Vector3D from utama_core.entities.game import Ball, Field, Game, GameFrame, GameHistory, Robot -from utama_core.shared.pass_and_score_geometry import enemy_defense_area_hold_point +from utama_core.shared.pass_and_score_geometry import ( + enemy_defense_area_hold_point, + enemy_goal_line, +) from utama_core.tactics.lead_and_support import LeadAndSupportTactic -def _robot(rid: int, x: float, y: float, is_friendly: bool) -> Robot: +def _robot(rid: int, x: float, y: float, is_friendly: bool, has_ball: bool = False) -> Robot: return Robot( id=rid, is_friendly=is_friendly, - has_ball=False, + has_ball=has_ball, p=Vector2D(x, y), v=Vector2D(0, 0), a=Vector2D(0, 0), @@ -104,3 +108,34 @@ def test_leader_chases_ball_directly_when_ball_is_not_in_enemy_defense_area(): # go_to_ball targets at (or an overshoot past) the ball itself, not a # defense-area hold point far from it. assert target.distance_to(Vector2D(1.0, 0.0)) < 0.5 + + +def test_leader_shot_y_does_not_flip_between_tied_gaps(): + """Two shot gaps tie (a centred blocker at x=-2 splits the goal symmetrically). + + A tiny alternating nudge of that blocker used to flip the unhysteresised + `find_best_shot` choice between +y and -y every tick, so the leader turned + back and forth and never reached `oriented_towards` to kick. With the + switch margin the first choice is kept while the gaps stay within it. + """ + leader = Vector2D(-0.11, 0.0) + goal_x, _goal_y1, _goal_y2 = enemy_goal_line(_game({}, {}, (0.0, 0.0))) + tactic = LeadAndSupportTactic() + mem = tactic.initial_mem() + + target_ys = [] + for tick in range(6): + nudge = 0.002 if tick % 2 == 0 else -0.002 + friendly = {1: _robot(1, leader.x, leader.y, True, has_ball=True)} + enemy = {0: _robot(0, -2.0, nudge, False)} + game = _game(friendly, enemy, (leader.x - 0.1, 0.0)) + ctx = _ctx() + + commands, mem = tactic.tick(game, ctx, robot_ids=(1,), mem=mem) + + assert 1 in commands + target_oren = ctx.motion_controller.calculate.call_args.kwargs["target_oren"] + target_ys.append(leader.y + (goal_x - leader.x) * math.tan(target_oren)) + + assert all(y > 0 for y in target_ys) or all(y < 0 for y in target_ys), target_ys + assert max(target_ys) - min(target_ys) < 0.05, target_ys diff --git a/utama_core/tests/skills/test_move_utils.py b/utama_core/tests/skills/test_move_utils.py index 8dfba287..27a25b0d 100644 --- a/utama_core/tests/skills/test_move_utils.py +++ b/utama_core/tests/skills/test_move_utils.py @@ -7,6 +7,7 @@ from utama_core.config.physical_constants import BALL_RADIUS, ROBOT_RADIUS from utama_core.entities.data.vector import Vector2D, Vector3D from utama_core.entities.game import Ball, Field, Game, GameFrame, GameHistory, Robot +from utama_core.global_utils.math_utils import rotate_vector from utama_core.skills.src.utils.move_utils import turn_on_spot PIVOT_RADIUS = ROBOT_RADIUS + BALL_RADIUS @@ -135,3 +136,38 @@ def test_turn_on_spot_steers_away_from_a_wedged_teammate(): final_call_kwargs = mc.calculate.call_args_list[-1].kwargs assert final_call_kwargs["target_pos"].y > 0.0 # steered away from the teammate at y=-0.15 + + +def test_turn_on_spot_blocked_pivot_drives_clear_instead_of_pushing_into_enemy(): + """Regression: when the pivot push is blocked by an enemy, `move()` steers + toward an escape point away from it, but turn_on_spot used to overwrite + that lateral command with the pivot term (-angular_vel * PIVOT_RADIUS), + so the chassis pushed straight into the enemy and never moved clear + (COMMITTED_FROZEN stall, tournament_20261011_205245). + + Geometry from the replay: friendly holding the ball at heading -35.4 deg, + enemy 0.185 m away on the pivot-push side. + """ + orientation = math.radians(-35.4) + robot_p = Vector2D(2.883, -0.135) + enemy_p = Vector2D(3.06, -0.08) + friendly = {1: _robot(1, robot_p.x, robot_p.y, True, orientation=orientation, has_ball=True)} + enemy = {0: _robot(0, enemy_p.x, enemy_p.y, False)} + ball_xy = (robot_p.x + PIVOT_RADIUS * math.cos(orientation), robot_p.y + PIVOT_RADIUS * math.sin(orientation)) + game = _game(friendly, enemy, ball_xy) + + # Stub controller: rotation is commanded as-is; translation is a global + # velocity pointing from the robot toward the requested target. + mc = MagicMock() + mc.calculate.side_effect = lambda game, robot_id, target_pos, target_oren: ( + Vector2D(target_pos.x - robot_p.x, target_pos.y - robot_p.y), + -1.0, # pivot push (-angular_vel * PIVOT_RADIUS) points into the enemy here + ) + + cmd = turn_on_spot(game=game, motion_controller=mc, robot_id=1, target_oren=math.radians(-2.0)) + + # Convert the commanded local velocity back to the global frame. + gx, gy = rotate_vector(cmd.local_forward_vel, cmd.local_left_vel, -orientation) + to_enemy = enemy_p - robot_p + # Must not drive into the blocker: motion must have a component away from it. + assert to_enemy.x * gx + to_enemy.y * gy < 0.0 diff --git a/utama_core/tests/strategy/test_split_block.py b/utama_core/tests/strategy/test_split_block.py new file mode 100644 index 00000000..2e3d2906 --- /dev/null +++ b/utama_core/tests/strategy/test_split_block.py @@ -0,0 +1,52 @@ +from types import SimpleNamespace + +from utama_core.entities.data.object import TeamType +from utama_core.strategy.split_block import _possession_split_picker + +_FIVE = frozenset({1, 2, 3, 4, 5}) +_BOTH = frozenset({"attack", "defense"}) + + +class _Proximity: + def __init__(self, friendly_dist, enemy_dist): + self._dists = {TeamType.FRIENDLY: friendly_dist, TeamType.ENEMY: enemy_dist} + + def closest_to_ball(self, team_type_filter=None): + return (None, self._dists[team_type_filter]) + + +def _game(friendly_dist, enemy_dist): + # Ball at the origin and at rest; no robot holds it, so `carrier_first` is plain id order. + far = SimpleNamespace(has_ball=False, p=SimpleNamespace(x=-9.0, y=0.0)) + return SimpleNamespace( + proximity_lookup=_Proximity(friendly_dist, enemy_dist), + ball=SimpleNamespace(p=SimpleNamespace(x=0.0, y=0.0), v=SimpleNamespace(x=0.0, y=0.0)), + friendly_robots={rid: far for rid in range(1, 6)}, + enemy_robots={}, + ) + + +def test_we_have_the_ball_edge_gives_attack_four_and_defense_one(): + partition = _possession_split_picker(_game(0.2, 1.5), _FIVE, None, _BOTH) + assert partition == {"attack": frozenset({1, 2, 3, 4}), "defense": frozenset({5})} + + +def test_they_have_the_ball_edge_gives_attack_one_and_defense_four(): + partition = _possession_split_picker(_game(1.5, 0.2), _FIVE, None, _BOTH) + assert partition == {"attack": frozenset({1}), "defense": frozenset({2, 3, 4, 5})} + + +def test_an_exact_tie_is_not_our_edge_so_it_takes_the_defence_heavy_split(): + # Strict `<` in the picker: equal distances must not count as our ball. + partition = _possession_split_picker(_game(0.5, 0.5), _FIVE, None, _BOTH) + assert partition == {"attack": frozenset({1}), "defense": frozenset({2, 3, 4, 5})} + + +def test_a_pinned_attack_leaves_every_robot_to_defense(): + partition = _possession_split_picker(_game(0.2, 1.5), _FIVE, None, frozenset({"defense"})) + assert partition == {"defense": _FIVE} + + +def test_a_pinned_defense_gives_every_robot_to_attack(): + partition = _possession_split_picker(_game(1.5, 0.2), _FIVE, None, frozenset({"attack"})) + assert partition == {"attack": _FIVE} diff --git a/utama_core/tests/strategy/test_split_outlet.py b/utama_core/tests/strategy/test_split_outlet.py new file mode 100644 index 00000000..31019a65 --- /dev/null +++ b/utama_core/tests/strategy/test_split_outlet.py @@ -0,0 +1,140 @@ +"""split_outlet: the outlet holding point (both attack directions) and the slot allocation.""" + +import pytest + +from utama_core.entities.data.vector import Vector2D +from utama_core.shared.pass_and_score_geometry import enemy_goal_line +from utama_core.strategy.split_outlet import _split_outlet_picker +from utama_core.tactics.outlet import holding_points +from utama_core.tests.fixtures.game_builder import build_game, make_ball, make_robot + +ALL_SLOTS = frozenset({"attack", "outlet", "defense"}) + + +def _game(ball_xy=(0.0, 0.0), my_team_is_right=True, friendly=None, enemy=None): + friendly = friendly if friendly is not None else {1: make_robot(1, is_friendly=True, x=-3.0, y=0.0)} + enemy = enemy if enemy is not None else {} + return build_game( + friendly, + enemy, + make_ball(*ball_xy), + my_team_is_right=my_team_is_right, + ) + + +@pytest.mark.parametrize("mine_right", [True, False]) +def test_holding_point_is_a_quarter_of_the_way_to_the_enemy_goal(mine_right): + game = _game(ball_xy=(0.0, 0.0), my_team_is_right=mine_right) + goal_x, _, _ = enemy_goal_line(game) + assert goal_x == (-4.5 if mine_right else 4.5) + + (point,) = holding_points(game, 1) + assert point.x == pytest.approx(0.25 * goal_x) + # The point is on the enemy side of halfway: the same sign as the enemy goal. + assert point.x * goal_x > 0 + + +@pytest.mark.parametrize("mine_right", [True, False]) +def test_holding_point_sits_on_the_side_away_from_the_ball(mine_right): + game = _game(ball_xy=(0.0, 2.0), my_team_is_right=mine_right) + (point,) = holding_points(game, 1) + assert point.y == pytest.approx(-0.6 * 2.0) + + game = _game(ball_xy=(0.0, -1.0), my_team_is_right=mine_right) + (point,) = holding_points(game, 1) + assert point.y == pytest.approx(0.6) + + +def test_holding_point_y_is_clamped_to_the_touchline_margin(): + # Ball at the far touchline on the left: -0.6 * (-5) = 3.0 would sit 0.5 m outside + # the margin (half_width 3.0 - 0.5 = 2.5), so the point stops at 2.5. + game = _game(ball_xy=(0.0, -5.0)) + (point,) = holding_points(game, 1) + assert point.y == pytest.approx(game.field.half_width - 0.5) + + +def test_multiple_holding_points_are_spread_along_y_and_stay_inside_the_margin(): + game = _game(ball_xy=(0.0, 0.0)) + points = holding_points(game, 2) + assert len(points) == 2 + assert points[0].y == pytest.approx(-0.75) + assert points[1].y == pytest.approx(0.75) + assert all(abs(p.y) <= game.field.half_width - 0.5 for p in points) + + +def test_holding_point_is_never_inside_the_enemy_defense_area(): + # Ball deep in the enemy half should not drag the point into the enemy box. + game = _game(ball_xy=(3.5, 0.0), my_team_is_right=True) + (point,) = holding_points(game, 1) + defense_area = game.field.enemy_defense_area + front_x = float(defense_area[1][0]) + assert not (point.x < front_x and abs(point.y) < abs(float(defense_area[0][1]))) + assert isinstance(point, Vector2D) + + +def _split(n, *, ball_edge, available=ALL_SLOTS): + """Partition for `n` free robots, with a friendly (edge) or enemy ball-edge game.""" + friendly = {rid: make_robot(rid, is_friendly=True, x=-1.0 - 0.5 * rid, y=0.0) for rid in range(1, n + 1)} + if ball_edge: + friendly[1] = make_robot(1, is_friendly=True, x=0.1, y=0.0) + enemy = {1: make_robot(1, is_friendly=False, x=3.0, y=0.0)} + else: + enemy = {1: make_robot(1, is_friendly=False, x=0.2, y=0.0)} + game = build_game(friendly, enemy, make_ball(0.0, 0.0), my_team_is_right=True) + return _split_outlet_picker(game, frozenset(range(1, n + 1)), None, available) + + +def _sizes(partition): + return {slot: len(robots) for slot, robots in partition.items()} + + +@pytest.mark.parametrize( + "n, expected", + [ + (1, {"defense": 1}), + (2, {"defense": 2}), + (3, {"attack": 1, "outlet": 1, "defense": 1}), + (4, {"attack": 1, "outlet": 1, "defense": 2}), + (5, {"attack": 1, "outlet": 1, "defense": 3}), + ], +) +def test_without_the_ball_edge_the_outlet_takes_one_robot_from_the_defence_from_three_up(n, expected): + assert _sizes(_split(n, ball_edge=False)) == expected + + +@pytest.mark.parametrize( + "n, expected", + [ + # Exactly split_shape's split with the ball edge: no outlet slot at all. + (1, {"attack": 1}), + (2, {"attack": 2}), + (3, {"attack": 3}), + (4, {"attack": 3, "defense": 1}), + (5, {"attack": 4, "defense": 1}), + ], +) +def test_with_the_ball_edge_there_is_no_outlet_slot(n, expected): + partition = _split(n, ball_edge=True) + assert _sizes(partition) == expected + assert "outlet" not in partition + + +def test_partition_covers_every_free_robot_exactly_once(): + for n in range(1, 6): + for edge in (True, False): + partition = _split(n, ball_edge=edge) + union = frozenset().union(*partition.values()) + assert union == frozenset(range(1, n + 1)) + assert sum(len(v) for v in partition.values()) == n + + +def test_an_unavailable_outlet_hands_its_robot_to_the_defence(): + partition = _split(5, ball_edge=False, available=frozenset({"attack", "defense"})) + assert _sizes(partition) == {"attack": 1, "defense": 4} + assert "outlet" not in partition + + +def test_no_zero_robot_entries_are_emitted(): + partition = _split(2, ball_edge=False) + assert all(len(v) > 0 for v in partition.values()) + assert "attack" not in partition diff --git a/utama_core/tests/strategy/test_split_outlet_wall.py b/utama_core/tests/strategy/test_split_outlet_wall.py new file mode 100644 index 00000000..0beb6fb5 --- /dev/null +++ b/utama_core/tests/strategy/test_split_outlet_wall.py @@ -0,0 +1,230 @@ +"""split_outlet_wall: the outlet slot allocation and the opponent direct-free-kick overrides. + +Allocation tests are copied from test_split_outlet.py; override dispatch tests are adapted +from test_split_wall.py and run through the factory's own Strategy. +""" + +import pytest + +from utama_core.config.field_params import STANDARD_FIELD_DIMS +from utama_core.config.referee_constants import BALL_KEEP_OUT_DISTANCE +from utama_core.engine.referee_override import RefereeOverride +from utama_core.entities.data.vector import Vector2D, Vector3D +from utama_core.entities.game import Ball, Field, Game, GameFrame, GameHistory, Robot +from utama_core.entities.referee.referee_command import RefereeCommand +from utama_core.strategy import split_outlet_wall +from utama_core.strategy.split_outlet_wall import ( + _split_outlet_picker, + build_split_outlet_wall_kernel_strategy, +) +from utama_core.tests.fixtures.game_builder import build_game, make_ball, make_robot + +ALL_SLOTS = frozenset({"attack", "outlet", "defense"}) + + +def _robot(rid, x, y, is_friendly): + return Robot( + id=rid, + is_friendly=is_friendly, + has_ball=False, + p=Vector2D(x, y), + v=Vector2D(0, 0), + a=Vector2D(0, 0), + orientation=0.0, + ) + + +def _game(ball_xy, friendly, enemy, my_team_is_right=True, my_team_is_yellow=True): + zv = Vector3D(0, 0, 0) + frame = GameFrame( + ts=0.0, + my_team_is_yellow=my_team_is_yellow, + my_team_is_right=my_team_is_right, + friendly_robots={rid: _robot(rid, x, y, True) for rid, (x, y) in friendly.items()}, + enemy_robots={rid: _robot(rid, x, y, False) for rid, (x, y) in enemy.items()}, + ball=Ball(p=Vector3D(ball_xy[0], ball_xy[1], 0), v=zv, a=zv), + ) + return Game( + past=GameHistory(10), + current=frame, + field=Field( + my_team_is_right=my_team_is_right, + field_dims=STANDARD_FIELD_DIMS, + field_bounds=STANDARD_FIELD_DIMS.full_field_bounds, + ), + ) + + +class _FakeMotion: + def calculate(self, **kwargs): + return Vector2D(0.0, 0.0), 0.0 + + +def _split(n, *, ball_edge, available=ALL_SLOTS): + """Partition for `n` free robots, with a friendly (edge) or enemy ball-edge game.""" + friendly = {rid: make_robot(rid, is_friendly=True, x=-1.0 - 0.5 * rid, y=0.0) for rid in range(1, n + 1)} + if ball_edge: + friendly[1] = make_robot(1, is_friendly=True, x=0.1, y=0.0) + enemy = {1: make_robot(1, is_friendly=False, x=3.0, y=0.0)} + else: + enemy = {1: make_robot(1, is_friendly=False, x=0.2, y=0.0)} + game = build_game(friendly, enemy, make_ball(0.0, 0.0), my_team_is_right=True) + return _split_outlet_picker(game, frozenset(range(1, n + 1)), None, available) + + +def _sizes(partition): + return {slot: len(robots) for slot, robots in partition.items()} + + +@pytest.mark.parametrize( + "n, expected", + [ + (1, {"defense": 1}), + (2, {"defense": 2}), + (3, {"attack": 1, "outlet": 1, "defense": 1}), + (4, {"attack": 1, "outlet": 1, "defense": 2}), + (5, {"attack": 1, "outlet": 1, "defense": 3}), + ], +) +def test_without_the_ball_edge_the_outlet_takes_one_robot_from_the_defence_from_three_up(n, expected): + assert _sizes(_split(n, ball_edge=False)) == expected + + +@pytest.mark.parametrize( + "n, expected", + [ + # Exactly split_shape's split with the ball edge: no outlet slot at all. + (1, {"attack": 1}), + (2, {"attack": 2}), + (3, {"attack": 3}), + (4, {"attack": 3, "defense": 1}), + (5, {"attack": 4, "defense": 1}), + ], +) +def test_with_the_ball_edge_there_is_no_outlet_slot(n, expected): + partition = _split(n, ball_edge=True) + assert _sizes(partition) == expected + assert "outlet" not in partition + + +def test_partition_covers_every_free_robot_exactly_once(): + for n in range(1, 6): + for edge in (True, False): + partition = _split(n, ball_edge=edge) + union = frozenset().union(*partition.values()) + assert union == frozenset(range(1, n + 1)) + assert sum(len(v) for v in partition.values()) == n + + +def test_an_unavailable_outlet_hands_its_robot_to_the_defence(): + partition = _split(5, ball_edge=False, available=frozenset({"attack", "defense"})) + assert _sizes(partition) == {"attack": 1, "defense": 4} + assert "outlet" not in partition + + +def test_no_zero_robot_entries_are_emitted(): + partition = _split(2, ball_edge=False) + assert all(len(v) > 0 for v in partition.values()) + assert "attack" not in partition + + +# ---- factory: both DIRECT_FREE overrides are wired ------------------------ + + +def _strategy(): + # The factory's Strategy is built from a bare stand-in motion controller, as in + # tests/engine/test_kernel_strategy.py. + return build_split_outlet_wall_kernel_strategy((1, 2, 3, 4, 5))(object()) + + +def test_factory_strategy_carries_both_direct_free_overrides(): + overrides = _strategy().referee_overrides + assert set(overrides) == {RefereeCommand.DIRECT_FREE_YELLOW, RefereeCommand.DIRECT_FREE_BLUE} + + +def test_factory_partitioner_is_the_outlet_picker(monkeypatch): + captured = {} + + class _Spy: + def __init__(self, **kwargs): + captured.update(kwargs) + + monkeypatch.setattr(split_outlet_wall, "KernelSchedulerStrategy", _Spy) + build_split_outlet_wall_kernel_strategy((1, 2, 3))(object()) + assert captured["partitioner"] is split_outlet_wall._split_outlet_picker + assert set(captured["tactics"]) == {"attack", "outlet", "defense"} + + +def test_our_own_direct_free_kick_runs_the_built_in_step_unchanged(monkeypatch): + calls = [] + monkeypatch.setattr(split_outlet_wall, "opponent_free_kick_targets", lambda g, ids: calls.append(1) or {}) + game = _game( + (0.0, 0.0), + friendly={1: (0.5, 0.1), 2: (-2.0, 1.0), 3: (-2.5, -1.0)}, + enemy={0: (3.0, 0.0)}, + my_team_is_yellow=True, + ) + ours = _strategy().referee_overrides[RefereeCommand.DIRECT_FREE_YELLOW](game, _FakeMotion()) + built_in = RefereeOverride().tick(game, _FakeMotion(), RefereeCommand.DIRECT_FREE_YELLOW) + + assert calls == [] + assert ours == built_in + + +def test_opponent_direct_free_kick_gets_the_wall(monkeypatch): + calls = [] + monkeypatch.setattr(split_outlet_wall, "opponent_free_kick_targets", lambda g, ids: calls.append(1) or {}) + # We are blue, so the opponent's (yellow) kick is DIRECT_FREE_YELLOW and must be walled. + game = _game( + (0.0, 0.0), + friendly={1: (0.5, 0.1), 2: (-2.0, 1.0)}, + enemy={0: (0.1, 0.0)}, + my_team_is_yellow=False, + ) + _strategy().referee_overrides[RefereeCommand.DIRECT_FREE_YELLOW](game, _FakeMotion()) + assert calls == [1] + + +def test_blue_key_is_the_opponents_kick_when_we_are_yellow(monkeypatch): + calls = [] + monkeypatch.setattr(split_outlet_wall, "opponent_free_kick_targets", lambda g, ids: calls.append(1) or {}) + game = _game( + (0.0, 0.0), + friendly={1: (0.5, 0.1), 2: (-2.0, 1.0)}, + enemy={0: (0.1, 0.0)}, + my_team_is_yellow=True, + ) + _strategy().referee_overrides[RefereeCommand.DIRECT_FREE_BLUE](game, _FakeMotion()) + assert calls == [1] + + +def test_wall_targets_are_the_split_wall_geometry_for_the_outlet_robot_too(): + # With the outlet robot among the outfield robots, it is one of the wall/markers at the + # opponent's kick (the override controls every outfield robot), not held by the picker. + game = _game( + (0.0, 0.0), + friendly={1: (0.5, 0.1), 2: (0.5, -0.2), 3: (-2.0, 1.0)}, + enemy={0: (0.1, 0.0)}, + my_team_is_yellow=False, + ) + targets = split_outlet_wall.opponent_free_kick_targets(game, (1, 2, 3)) + assert set(targets) == {1, 2, 3} + assert targets[1][0].x == pytest.approx(0.9) and targets[1][0].y == pytest.approx(0.1) + assert targets[2][0].x == pytest.approx(0.9) and targets[2][0].y == pytest.approx(-0.1) + assert isinstance(targets[3][0], Vector2D) + + +def test_restart_targets_clamped_to_the_box_edge_stay_outside_the_keep_out_distance(): + # Same real foul state as test_split_wall: the box clamp used to pull wall robots to + # 0.43-0.46 m from a ball 0.5 m out from our box's front edge (the 0.5 m foul radius). + ball = Vector2D(-2.5, 0.092) + game = _game( + (ball.x, ball.y), + friendly={1: (-1.0, 1.5), 2: (-1.2, -1.5), 3: (-2.6, 0.6), 4: (-2.4, -0.5), 5: (-1.5, 1.0)}, + enemy={0: (-2.45, 0.1), 1: (-1.0, 0.0)}, + my_team_is_right=False, + ) + targets = split_outlet_wall.opponent_free_kick_targets(game, (1, 2, 3, 4, 5)) + for rid, (point, _) in targets.items(): + assert point.distance_to(ball) >= BALL_KEEP_OUT_DISTANCE - 1e-6, (rid, point) + assert point.x > float(game.field.my_defense_area[1][0]) diff --git a/utama_core/tests/strategy/test_split_press.py b/utama_core/tests/strategy/test_split_press.py new file mode 100644 index 00000000..871ce1d4 --- /dev/null +++ b/utama_core/tests/strategy/test_split_press.py @@ -0,0 +1,93 @@ +from types import SimpleNamespace + +import pytest + +from utama_core.entities.data.object import TeamType +from utama_core.strategy.split_press import _split_press_picker + +ALL_TACTICS = frozenset({"attack", "defense", "press"}) + + +def _game(n, friendly_dist, enemy_dist): + """n free friendly robots, none holding the ball, ball moving so no still-ball kicker.""" + distances = {TeamType.FRIENDLY: (1, friendly_dist), TeamType.ENEMY: (1, enemy_dist)} + robot = SimpleNamespace(has_ball=False, p=SimpleNamespace(x=0.0, y=0.0)) + return SimpleNamespace( + proximity_lookup=SimpleNamespace(closest_to_ball=lambda team_type_filter: distances[team_type_filter]), + ball=SimpleNamespace(p=SimpleNamespace(x=0.0, y=0.0), v=SimpleNamespace(x=1.0, y=0.0)), + friendly_robots={rid: robot for rid in range(1, n + 1)}, + enemy_robots={1: robot}, + ) + + +def _picker(n, available, friendly_dist=1.0, enemy_dist=0.5): + return _split_press_picker( + _game(n, friendly_dist, enemy_dist), frozenset(range(1, n + 1)), None, frozenset(available) + ) + + +def _assert_covers(partition, n): + assert set().union(*partition.values()) == set(range(1, n + 1)) + assert all(robots for robots in partition.values()), "no empty frozenset may be emitted" + + +@pytest.mark.parametrize("n", [2, 3, 4, 5]) +def test_press_available_without_ball_edge_presses_with_two_and_defends_the_rest(n): + partition = _picker(n, ALL_TACTICS) + assert partition["press"] == frozenset({1, 2}) + assert partition.get("defense", frozenset()) == frozenset(range(3, n + 1)) + assert "attack" not in partition + _assert_covers(partition, n) + + +@pytest.mark.parametrize("n", [2, 3, 4, 5]) +def test_press_not_applicable_falls_back_to_split_shape_split(n): + # Without an enemy in press range the split is split_shape's: attack n//2-1, defense rest. + partition = _picker(n, {"attack", "defense"}) + attack_count = max(0, n // 2 - 1) + assert partition.get("attack", frozenset()) == frozenset(range(1, attack_count + 1)) + assert partition["defense"] == frozenset(range(attack_count + 1, n + 1)) + assert "press" not in partition + _assert_covers(partition, n) + + +@pytest.mark.parametrize("n", [2, 3, 4, 5]) +def test_ball_edge_keeps_split_shape_split_even_with_press_available(n): + partition = _picker(n, ALL_TACTICS, friendly_dist=0.2, enemy_dist=0.9) + attack_count = (n + 1) // 2 + 1 + assert partition["attack"] == frozenset(range(1, attack_count + 1)) + assert "press" not in partition + _assert_covers(partition, n) + + +def test_press_boundary_is_exactly_the_press_availability(): + # Same geometry, only availability differs: the press slot decides the shape. + with_press = _picker(4, ALL_TACTICS) + without_press = _picker(4, {"attack", "defense"}) + assert "press" in with_press and "press" not in without_press + + +def test_tie_at_the_ball_is_not_a_friendly_ball_edge(): + # Ties go to the press (friendly_dist < enemy_dist is strict, as in split_shape). + partition = _picker(4, ALL_TACTICS, friendly_dist=0.5, enemy_dist=0.5) + assert partition["press"] == frozenset({1, 2}) + + +@pytest.mark.parametrize("n", [1]) +def test_a_single_robot_presses_alone(n): + partition = _picker(n, ALL_TACTICS) + assert partition == {"press": frozenset({1})} + + +def test_two_robots_press_and_neither_is_left_for_defense(): + assert _picker(2, ALL_TACTICS) == {"press": frozenset({1, 2})} + + +def test_press_gets_all_when_nothing_left_for_defense_or_attack(): + partition = _picker(3, {"press"}) + assert partition == {"press": frozenset({1, 2})} + + +def test_no_robots_gives_no_partition(): + game = _game(1, 1.0, 0.5) + assert _split_press_picker(game, frozenset(), None, ALL_TACTICS) == {} diff --git a/utama_core/tests/strategy/test_split_sticky.py b/utama_core/tests/strategy/test_split_sticky.py new file mode 100644 index 00000000..65d56ef6 --- /dev/null +++ b/utama_core/tests/strategy/test_split_sticky.py @@ -0,0 +1,74 @@ +from types import SimpleNamespace + +from utama_core.entities.data.object import TeamType +from utama_core.strategy.split_sticky import _sticky_split_picker + +ALL = frozenset({1, 2, 3, 4, 5}) +ALL_TACTICS = frozenset({"attack", "defense"}) + + +def _game(friendly_dist, enemy_dist): + """Fake Game: ball gap only, no carrier. `friendly_closer_to_ball` reads the proximity lookup.""" + distances = {TeamType.FRIENDLY: (1, friendly_dist), TeamType.ENEMY: (2, enemy_dist)} + return SimpleNamespace( + proximity_lookup=SimpleNamespace(closest_to_ball=lambda team_type_filter: distances[team_type_filter]), + friendly_robots={rid: SimpleNamespace(has_ball=False) for rid in ALL}, + enemy_robots={2: SimpleNamespace(has_ball=False)}, + ) + + +def _pick(game, prev_attack_n): + """Previous partition with `prev_attack_n` robots on attack (rest on defense), or no history.""" + prev = None + if prev_attack_n is not None: + ids = sorted(ALL) + prev = {"attack": frozenset(ids[:prev_attack_n]), "defense": frozenset(ids[prev_attack_n:])} + return _sticky_split_picker(game, ALL, prev, ALL_TACTICS) + + +def _attack_size(partition): + return len(partition.get("attack", ())) + + +# With 5 free robots: attacking posture is (5+1)//2+1 = 4 attackers, defence posture is 5//2-1 = 1. +ATTACKING, DEFENDING = 4, 1 +NEAR_TIE = _game(0.30, 0.30) +WE_WIN = _game(0.10, 0.30) +THEY_WIN = _game(0.30, 0.10) + + +def test_near_tie_keeps_attacking_posture_once_held(): + # Held attack last tick (4 vs 1): a None edge must not hand the ball back. + assert _attack_size(_pick(NEAR_TIE, ATTACKING)) == ATTACKING + + +def test_near_tie_keeps_defending_posture_when_not_held(): + # Last tick was defence-heavy (1 vs 4): a None edge must not flip to attack. + assert _attack_size(_pick(NEAR_TIE, DEFENDING)) == DEFENDING + + +def test_no_history_near_tie_defends(): + assert _attack_size(_pick(NEAR_TIE, None)) == DEFENDING + + +def test_clear_loss_gives_up_attacking_posture(): + assert _attack_size(_pick(THEY_WIN, ATTACKING)) == DEFENDING + + +def test_clear_gain_is_needed_to_take_attacking_posture(): + assert _attack_size(_pick(WE_WIN, DEFENDING)) == ATTACKING + assert _attack_size(_pick(WE_WIN, None)) == ATTACKING + + +def test_clear_loss_does_not_flip_defending_posture_further(): + assert _attack_size(_pick(THEY_WIN, DEFENDING)) == DEFENDING + + +def test_split_sizes_match_split_shape(): + # Same split as split_shape: attack (n+1)//2+1 with the edge, n//2-1 without. + for n in range(1, 6): + ids = frozenset(range(1, n + 1)) + game = _game(0.10, 0.30) + part = _sticky_split_picker(game, ids, None, ALL_TACTICS) + assert _attack_size(part) == min(n, (n + 1) // 2 + 1) + assert frozenset().union(*part.values()) == ids diff --git a/utama_core/tests/strategy/test_split_wall.py b/utama_core/tests/strategy/test_split_wall.py new file mode 100644 index 00000000..48e56245 --- /dev/null +++ b/utama_core/tests/strategy/test_split_wall.py @@ -0,0 +1,259 @@ +"""Geometry and dispatch of split_wall's opponent direct-free-kick override. + +Pins the wall's exact distance and spacing (on both field sides), the marker and +leftover positions, and that only the OPPONENT's direct free kick gets the wall: our +own kick must run the built-in DirectFreeOursStep unchanged. +""" + +from __future__ import annotations + +import math +from types import SimpleNamespace + +import pytest + +from utama_core.config.field_params import STANDARD_FIELD_DIMS +from utama_core.config.referee_constants import BALL_KEEP_OUT_DISTANCE +from utama_core.engine.referee_override import RefereeOverride +from utama_core.entities.data.vector import Vector2D, Vector3D +from utama_core.entities.game import Ball, Field, Game, GameFrame, GameHistory, Robot +from utama_core.entities.referee.referee_command import RefereeCommand +from utama_core.shared.pass_and_score_geometry import _DEFENSE_AREA_CLAMP_MARGIN +from utama_core.strategy import split_wall +from utama_core.strategy.split_wall import ( + WALL_DISTANCE_M, + WALL_SPACING_M, + leftover_points, + marker_point, + wall_points, +) + + +def _robot(rid, x, y, is_friendly, orientation=0.0): + return Robot( + id=rid, + is_friendly=is_friendly, + has_ball=False, + p=Vector2D(x, y), + v=Vector2D(0, 0), + a=Vector2D(0, 0), + orientation=orientation, + ) + + +def _game(ball_xy, friendly, enemy, my_team_is_right=True, my_team_is_yellow=True): + zv = Vector3D(0, 0, 0) + frame = GameFrame( + ts=0.0, + my_team_is_yellow=my_team_is_yellow, + my_team_is_right=my_team_is_right, + friendly_robots={rid: _robot(rid, x, y, True) for rid, (x, y) in friendly.items()}, + enemy_robots={rid: _robot(rid, x, y, False) for rid, (x, y) in enemy.items()}, + ball=Ball(p=Vector3D(ball_xy[0], ball_xy[1], 0), v=zv, a=zv), + ) + return Game( + past=GameHistory(10), + current=frame, + field=Field( + my_team_is_right=my_team_is_right, + field_dims=STANDARD_FIELD_DIMS, + field_bounds=STANDARD_FIELD_DIMS.full_field_bounds, + ), + ) + + +def _along(p: Vector2D, start: Vector2D, end: Vector2D) -> float: + """Signed distance of p along the start->end direction.""" + length = math.hypot(end.x - start.x, end.y - start.y) + return ((p.x - start.x) * (end.x - start.x) + (p.y - start.y) * (end.y - start.y)) / length + + +def _across(p: Vector2D, start: Vector2D, end: Vector2D) -> float: + """Signed distance of p across the start->end direction.""" + length = math.hypot(end.x - start.x, end.y - start.y) + return ((p.x - start.x) * (end.y - start.y) - (p.y - start.y) * (end.x - start.x)) / length + + +# ---- wall geometry ------------------------------------------------------- + + +def test_wall_sits_on_the_ball_to_goal_line_at_the_keep_out_distance_on_our_right_side(): + ball, goal = Vector2D(0.0, 0.0), Vector2D(4.5, 0.0) + left, right = wall_points(ball, goal, 2) + # Both on the x = 0.9 line, 0.2 m apart, symmetric about the ball->goal line. + assert WALL_DISTANCE_M == pytest.approx(0.9) + assert left.x == pytest.approx(0.9) and right.x == pytest.approx(0.9) + assert left.y == pytest.approx(-0.1) and right.y == pytest.approx(0.1) + assert right.y - left.y == pytest.approx(WALL_SPACING_M) + + +def test_wall_mirrors_on_the_left_side_where_our_goal_is_at_negative_x(): + ball, goal = Vector2D(0.0, 0.0), Vector2D(-4.5, 0.0) + pts = sorted(wall_points(ball, goal, 2), key=lambda p: p.y) + assert [p.x for p in pts] == pytest.approx([-0.9, -0.9]) + assert [p.y for p in pts] == pytest.approx([-0.1, 0.1]) + + +def test_wall_distance_is_measured_along_an_off_axis_ball_to_goal_line(): + ball, goal = Vector2D(1.0, 1.0), Vector2D(4.5, 0.0) + pts = wall_points(ball, goal, 2) + assert [_along(p, ball, goal) for p in pts] == pytest.approx([WALL_DISTANCE_M] * 2) + assert sorted(_across(p, ball, goal) for p in pts) == pytest.approx([-WALL_SPACING_M / 2, WALL_SPACING_M / 2]) + + +def test_wall_clears_the_0_5_m_foul_line_with_margin(): + # The referee's keep-out foul is 0.5 m; the wall must sit beyond it. + ball, goal = Vector2D(0.0, 0.0), Vector2D(4.5, 0.0) + for p in wall_points(ball, goal, 2): + assert p.distance_to(ball) > 0.5 + 0.3 + + +# ---- marking and leftover geometry --------------------------------------- + + +def test_marker_stands_half_a_metre_goal_side_of_its_man_on_the_man_to_goal_line(): + man, goal = Vector2D(2.0, 1.0), Vector2D(4.5, 0.0) + mark = marker_point(man, goal) + assert mark.distance_to(man) == pytest.approx(0.5) + assert _across(mark, man, goal) == pytest.approx(0.0, abs=1e-9) + assert mark.distance_to(goal) < man.distance_to(goal) + + +def test_leftovers_sit_on_the_defense_area_front_edge_where_the_ball_goal_line_crosses_it(): + ball, goal = Vector2D(0.0, 0.0), Vector2D(4.5, 0.0) + (single,) = leftover_points(ball, goal, 3.5, 1) + assert single.x == pytest.approx(3.5) + assert single.y == pytest.approx(0.0) + two = leftover_points(ball, goal, 3.5, 2) + assert two[1].y - two[0].y == pytest.approx(split_wall.LEFTOVER_SPACING_M) + assert all(p.x == pytest.approx(3.5) for p in two) + + +# ---- full target assignment ---------------------------------------------- + + +def test_opponent_kick_assigns_wall_markers_and_leftover_on_our_right_side(): + # Kicker: enemy 0 is nearest the ball. Enemy 1 is nearest our goal, enemy 2 next. + game = _game( + (0.0, 0.0), + friendly={1: (0.5, 0.1), 2: (0.5, -0.2), 3: (-2.0, 1.0), 4: (-2.5, -1.0), 5: (-1.0, 0.0)}, + enemy={0: (0.1, 0.0), 1: (3.0, 1.0), 2: (1.0, -2.0)}, + ) + targets = split_wall.opponent_free_kick_targets(game, (1, 2, 3, 4, 5)) + + assert set(targets) == {1, 2, 3, 4, 5} + # Wall: robot 2 (lower side) takes y=-0.1, robot 1 takes y=+0.1. + assert targets[2][0].x == pytest.approx(0.9) and targets[2][0].y == pytest.approx(-0.1) + assert targets[1][0].x == pytest.approx(0.9) and targets[1][0].y == pytest.approx(0.1) + # Markers: robot 5 marks enemy 1, robot 4 marks enemy 2. + assert targets[5][0].distance_to(game.enemy_robots[1].p) < 1.0 + assert targets[4][0].distance_to(game.enemy_robots[2].p) < 1.0 + # Leftover robot 3 goes to the front edge, pushed out of the box by the clamp margin. + front_x = float(game.field.my_defense_area[1][0]) + assert targets[3][0].x == pytest.approx(front_x - _DEFENSE_AREA_CLAMP_MARGIN) + assert targets[3][0].y == pytest.approx(0.0) + + +def test_opponent_kick_mirrors_on_the_left_side(): + game = _game( + (0.0, 0.0), + friendly={1: (-0.5, 0.1), 2: (-0.5, -0.2), 3: (2.0, 1.0)}, + enemy={0: (-0.1, 0.0)}, + my_team_is_right=False, + ) + targets = split_wall.opponent_free_kick_targets(game, (1, 2, 3)) + wall = sorted((targets[1][0], targets[2][0]), key=lambda p: p.y) + assert wall[0].x == pytest.approx(-0.9) and wall[0].y == pytest.approx(-0.1) + assert wall[1].x == pytest.approx(-0.9) and wall[1].y == pytest.approx(0.1) + + +def test_restart_targets_clamped_to_the_box_edge_stay_outside_the_keep_out_distance(): + # Real foul state (split_outlet_vs_split_wall, t=66-69 s): ball 0.5 m out from our box's + # front edge. The wall points (0.9 m out on the goal line) fall behind the edge and the + # box clamp pulled robots 3 and 4 to 0.43-0.46 m from the ball, inside the 0.5 m foul radius. + ball = Vector2D(-2.5, 0.092) + game = _game( + (ball.x, ball.y), + friendly={1: (-1.0, 1.5), 2: (-1.2, -1.5), 3: (-2.6, 0.6), 4: (-2.4, -0.5), 5: (-1.5, 1.0)}, + enemy={0: (-2.45, 0.1), 1: (-1.0, 0.0)}, + my_team_is_right=False, + ) + targets = split_wall.opponent_free_kick_targets(game, (1, 2, 3, 4, 5)) + for rid in (3, 4): + point = targets[rid][0] + assert point.distance_to(ball) >= BALL_KEEP_OUT_DISTANCE - 1e-6, (rid, point) + # Still outside our defense area (x beyond its front edge at -3.5). + assert point.x > float(game.field.my_defense_area[1][0]) + + +def test_marker_inside_the_keep_out_distance_is_pushed_out_to_exactly_that_distance(): + # Enemy 1 stands 0.2 m from the ball on our goal side; its marker (0.5 m goal-side of it) + # would land 0.7 m from the ball, inside BALL_KEEP_OUT_DISTANCE. It must sit on the circle. + game = _game( + (0.0, 0.0), + friendly={1: (-0.5, 0.5), 2: (-0.5, -0.5), 3: (2.0, 2.0)}, + enemy={0: (-0.1, 0.0), 1: (0.2, 0.0)}, + ) + targets = split_wall.opponent_free_kick_targets(game, (1, 2, 3)) + marker, _ = targets[3] + assert marker.distance_to(Vector2D(0.0, 0.0)) == pytest.approx(BALL_KEEP_OUT_DISTANCE) + # Moved onto the circle clear of the wall's spots (0.2 m apart), not stacked on them. + for rid in (1, 2): + assert marker.distance_to(targets[rid][0]) >= split_wall.WALL_SPACING_M - 1e-9 + + +# ---- dispatch: opponent's kick gets the wall, ours does not --------------- + + +class _FakeMotion: + def calculate(self, **kwargs): + return Vector2D(0.0, 0.0), 0.0 + + +def _override_for(command_is_yellow, outfield_ids=(1, 2, 3, 4, 5)): + return split_wall._restart_override(command_is_yellow, outfield_ids, split_wall.DirectFreeOursStep()) + + +def test_our_own_direct_free_kick_runs_the_built_in_step_unchanged(monkeypatch): + # We are yellow and the referee gives us DIRECT_FREE_YELLOW: not the wall. + calls = [] + monkeypatch.setattr(split_wall, "opponent_free_kick_targets", lambda g, ids: calls.append(1) or {}) + game = _game( + (0.0, 0.0), + friendly={1: (0.5, 0.1), 2: (-2.0, 1.0), 3: (-2.5, -1.0)}, + enemy={0: (3.0, 0.0)}, + my_team_is_yellow=True, + ) + ours = _override_for(True)(game, _FakeMotion()) + built_in = RefereeOverride().tick(game, _FakeMotion(), RefereeCommand.DIRECT_FREE_YELLOW) + + assert calls == [] + assert ours == built_in + + +def test_opponent_direct_free_kick_gets_the_wall(monkeypatch): + calls = [] + monkeypatch.setattr(split_wall, "opponent_free_kick_targets", lambda g, ids: calls.append(1) or {}) + # We are blue, so the opponent's (yellow) kick is DIRECT_FREE_YELLOW and must be walled. + game = _game( + (0.0, 0.0), + friendly={1: (0.5, 0.1), 2: (-2.0, 1.0)}, + enemy={0: (0.1, 0.0)}, + my_team_is_yellow=False, + ) + _override_for(True)(game, _FakeMotion()) + assert calls == [1] + + +def test_blue_key_is_the_opponents_kick_when_we_are_yellow(monkeypatch): + calls = [] + monkeypatch.setattr(split_wall, "opponent_free_kick_targets", lambda g, ids: calls.append(1) or {}) + game = _game( + (0.0, 0.0), + friendly={1: (0.5, 0.1), 2: (-2.0, 1.0)}, + enemy={0: (0.1, 0.0)}, + my_team_is_yellow=True, + ) + # We are yellow: the BLUE key is the opponent's kick, so it must be walled. + _override_for(False)(game, _FakeMotion()) + assert calls == [1]