diff --git a/docs/strategies.md b/docs/strategies.md index adee01be..bee5e184 100644 --- a/docs/strategies.md +++ b/docs/strategies.md @@ -52,6 +52,7 @@ only from `--both-sides`, side/kickoff cells, or `--fuzz-restarts`. | `press_trigger_flow` | competitive | `counter_flow`, but an all-in press when the ball is lost in our own third. | | `overload_press` | parked | 4-robot overload/switch attack + 1 block, built to outnumber tiki_taka's shadow line. | | `high_line_zone` | parked | Zone screen (`BlockShapeTactic`) + switch attack, built to deny tiki_taka 1v1s. | +| `zone_split_shape` | experimental | `split_shape`'s tactics; the 4/1 possession split shifts one robot by the ball's third (3/2 building out from our own third, 2/3 when the ball is lost in theirs), near-ties keep the last split. | ## Latest results diff --git a/utama_core/strategy/kernel_strategy.py b/utama_core/strategy/kernel_strategy.py index 63881430..2d76e977 100644 --- a/utama_core/strategy/kernel_strategy.py +++ b/utama_core/strategy/kernel_strategy.py @@ -69,3 +69,7 @@ _zone_flow_picker, build_zone_fluid_kernel_strategy, ) +from utama_core.strategy.zone_split_shape import ( + _zone_split_picker, + build_zone_split_shape_kernel_strategy, +) diff --git a/utama_core/strategy/zone_split_shape.py b/utama_core/strategy/zone_split_shape.py new file mode 100644 index 00000000..4e1b807c --- /dev/null +++ b/utama_core/strategy/zone_split_shape.py @@ -0,0 +1,82 @@ +"""The `zone_split_shape` kernel strategy: `split_shape`'s two tactics with a split that reads the ball's third.""" + +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 ( + ball_zone, + 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 _attack_count(n: int, edge: Optional[bool], zone: str, prev_attack: Optional[int]) -> int: + """How many of `n` free robots attack. + + With the ball: 4 of 5 in the middle and final thirds, but one fewer in our own third, so a + build-out from the back keeps cover. Without it: 1 of 5 when the ball is in our own or middle + third, but 2 when it is in their final third, so a robot stays up to win the ball back there. + A near-tie (`edge` None) keeps the previous count. + """ + if edge is None: + if prev_attack is not None: + return max(0, min(n, prev_attack)) + edge = False + if edge: + count = (n + 1) // 2 + 1 + if zone == "own": + count -= 1 + else: + count = n // 2 - 1 + if zone == "final": + count += 1 + return max(0, min(n, count)) + + +def _zone_split_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, shifted one robot by where the ball is. See `_attack_count`.""" + ordered = carrier_first(game, free_robots) + if not ordered: + return {} + 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)} + + prev_attack = len(prev_partition["attack"]) if prev_partition and "attack" in prev_partition else None + attack_count = _attack_count(len(ordered), friendly_closer_to_ball(game), ball_zone(game), prev_attack) + + 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_zone_split_shape_kernel_strategy(outfield_robot_ids: tuple[int, ...]): + """`split_shape` with the split shifted by the ball's third of the pitch.""" + + def _build(motion_controller: MotionController) -> KernelSchedulerStrategy: + ctx = TickContext(motion_controller=motion_controller) + return KernelSchedulerStrategy( + tactics={"attack": LeadAndSupportTactic(), "defense": ShadowAndMarkTactic()}, + partitioner=_zone_split_picker, + outfield_robot_ids=outfield_robot_ids, + ctx=ctx, + ) + + return _build diff --git a/utama_core/tests/strategy/test_zone_split_shape.py b/utama_core/tests/strategy/test_zone_split_shape.py new file mode 100644 index 00000000..398cfd6b --- /dev/null +++ b/utama_core/tests/strategy/test_zone_split_shape.py @@ -0,0 +1,74 @@ +from types import SimpleNamespace + +import pytest + +from utama_core.entities.data.object import TeamType +from utama_core.strategy.kernel_strategy import _zone_split_picker +from utama_core.strategy.zone_split_shape import _attack_count + +FREE = frozenset({1, 2, 3, 4, 5}) +BOTH = frozenset({"attack", "defense"}) + + +@pytest.mark.parametrize( + "edge, zone, expected", + [ + (True, "own", 3), + (True, "mid", 4), + (True, "final", 4), + (False, "own", 1), + (False, "mid", 1), + (False, "final", 2), + ], +) +def test_five_robot_split_by_possession_and_third(edge, zone, expected): + assert _attack_count(5, edge, zone, prev_attack=None) == expected + + +def test_a_near_tie_keeps_the_previous_count_whatever_the_third(): + assert _attack_count(5, None, "own", prev_attack=4) == 4 + assert _attack_count(5, None, "final", prev_attack=1) == 1 + + +def test_a_near_tie_with_no_history_is_the_defensive_split(): + assert _attack_count(5, None, "mid", prev_attack=None) == 1 + + +def test_counts_stay_within_the_robots_available(): + assert _attack_count(1, True, "mid", None) == 1 + assert _attack_count(2, False, "final", None) == 1 + assert _attack_count(0, True, "mid", None) == 0 + + +def _game(ball_x, friendly_dist, enemy_dist, carrier=None): + distances = {TeamType.FRIENDLY: (3, friendly_dist), TeamType.ENEMY: (1, enemy_dist)} + return SimpleNamespace( + proximity_lookup=SimpleNamespace(closest_to_ball=lambda team_type_filter: distances[team_type_filter]), + my_team_is_right=True, + field=SimpleNamespace(half_length=4.5), + ball=SimpleNamespace( + p=SimpleNamespace(to_2d=lambda: SimpleNamespace(x=ball_x)), v=SimpleNamespace(x=1.0, y=0.0) + ), + friendly_robots={rid: SimpleNamespace(has_ball=rid == carrier) for rid in range(1, 6)}, + enemy_robots={1: SimpleNamespace(has_ball=False)}, + ) + + +def test_building_out_from_our_own_third_keeps_a_second_defender_back(): + # my_team_is_right: our goal is at +x, so x = +3.2 is our own third. + partition = _zone_split_picker(_game(3.2, 0.2, 0.8, carrier=3), FREE, None, BOTH) + assert len(partition["attack"]) == 3 and len(partition["defense"]) == 2 + assert 3 in partition["attack"] + + +def test_losing_the_ball_in_their_third_keeps_a_robot_up(): + partition = _zone_split_picker(_game(-3.2, 0.8, 0.2), FREE, None, BOTH) + assert len(partition["attack"]) == 2 and len(partition["defense"]) == 3 + + +def test_a_pinned_slot_gets_every_free_robot(): + only_defense = _zone_split_picker(_game(0.0, 0.2, 0.8), FREE, None, frozenset({"defense"})) + assert only_defense == {"defense": FREE} + only_attack = _zone_split_picker(_game(0.0, 0.2, 0.8), FREE, None, frozenset({"attack"})) + assert only_attack == {"attack": FREE} + assert _zone_split_picker(_game(0.0, 0.2, 0.8), FREE, None, frozenset()) == {}