Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 0 additions & 7 deletions docs/STRATEGY_DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
41 changes: 41 additions & 0 deletions docs/strategies.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 5 additions & 1 deletion utama_core/skills/src/utils/move_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -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)

Expand Down
8 changes: 8 additions & 0 deletions utama_core/strategy/kernel_strategy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
93 changes: 93 additions & 0 deletions utama_core/strategy/split_block.py
Original file line number Diff line number Diff line change
@@ -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
100 changes: 100 additions & 0 deletions utama_core/strategy/split_outlet.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading