From 1ae00b04049edceee07b963405aa22374eccca1e Mon Sep 17 00:00:00 2001 From: isaac0804 Date: Sat, 3 Oct 2026 09:52:22 +0100 Subject: [PATCH 1/2] Add zone_split_shape: split_shape with the split shifted by the ball's third Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01SACGSprm9GiBueqKdxsy2j --- docs/strategies.md | 13 +++ utama_core/strategy/kernel_strategy.py | 4 + utama_core/strategy/zone_split_shape.py | 82 +++++++++++++++++++ .../tests/strategy/test_zone_split_shape.py | 74 +++++++++++++++++ 4 files changed, 173 insertions(+) create mode 100644 utama_core/strategy/zone_split_shape.py create mode 100644 utama_core/tests/strategy/test_zone_split_shape.py diff --git a/docs/strategies.md b/docs/strategies.md index aabcbc7d..54e784af 100644 --- a/docs/strategies.md +++ b/docs/strategies.md @@ -53,6 +53,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 @@ -87,6 +88,18 @@ to noise; two round-robins of near-identical code rank strategies with Spearman | `shadow_switch` | 2-8-11 | 4-13 | 0.67 | | `score_aware_zone_flow` | 0-13-8 | 0-9 | 0.62 | +**`zone_split_shape` pair matches** (2026-10-03, `--pair`, 65s, `zone_split_shape` as config_a, 5 matches, +0 stalls, not a round-robin: `--reuse` missed every match, the cache predating the `analysis/` split +and the `pickers.py` change, so the 253-match run was not played). Not comparable with the table above. + +| Opponent | Result | +|---|---| +| `split_shape` | 2-0 | +| `counter_flow` | 1-0 | +| `tiki_taka` | 1-0 | +| `low_block` | 1-0 | +| `clear_danger` | 0-0 | + The full-match tables below predate the planner, referee and tactic fixes since 2026-09-02. Treat them as rough ordering, not current truth. 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()) == {} From ad0f77f2455616de94f5c213a2bfe887e395bd63 Mon Sep 17 00:00:00 2001 From: isaac0804 Date: Sat, 3 Oct 2026 10:46:19 +0100 Subject: [PATCH 2/2] strategies.md: pair matches belong in the PR, not the catalog Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SACGSprm9GiBueqKdxsy2j --- docs/strategies.md | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/docs/strategies.md b/docs/strategies.md index 83f5e673..bee5e184 100644 --- a/docs/strategies.md +++ b/docs/strategies.md @@ -87,18 +87,6 @@ to noise; two round-robins of near-identical code rank strategies with Spearman | `shadow_switch` | 2-8-11 | 4-13 | 0.67 | | `score_aware_zone_flow` | 0-13-8 | 0-9 | 0.62 | -**`zone_split_shape` pair matches** (2026-10-03, `--pair`, 65s, `zone_split_shape` as config_a, 5 matches, -0 stalls, not a round-robin: `--reuse` missed every match, the cache predating the `analysis/` split -and the `pickers.py` change, so the 253-match run was not played). Not comparable with the table above. - -| Opponent | Result | -|---|---| -| `split_shape` | 2-0 | -| `counter_flow` | 1-0 | -| `tiki_taka` | 1-0 | -| `low_block` | 1-0 | -| `clear_danger` | 0-0 | - The full-match tables below predate the planner, referee and tactic fixes since 2026-09-02. Treat them as rough ordering, not current truth.