diff --git a/CLAUDE.md b/CLAUDE.md index cef3c5e..be8dba9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,7 +11,8 @@ You are the ORCHESTRATOR (largest model in this session). You plan, dispatch, re | docs/source/03_Data_Model_and_Tactical_Content_Spec.md | Schema, seed rules, animation spec format | Platform + content-seeder + board tickets | | docs/source/04_Tech_Stack_Decision_Note.md | Stack decisions (FastAPI, SQLAlchemy 2 + Alembic, SQLite WAL, Litestream, React + Vite) | T-001 and any infra question | | docs/source/01_PRD_v2_Patterns_of_Play.md | Product intent, later phases | Ambiguity resolution only | -On conflict: Brief wins scope, design README wins visuals and permissions, Bible wins content, doc 03 wins schema. Do not relitigate doc 04 decisions. +| docs/source/06_Tactical_Depth_Spec.md | Tactical Depth epic (T-100 series): the scope amendment, formation matchup overlay, Rondo Map (6 zones, all formations), auto footedness, unit-balance clash warnings | Any T-100 epic ticket, section by section | +On conflict: Brief wins scope, design README wins visuals and permissions, Bible wins content, doc 03 wins schema. Do not relitigate doc 04 decisions. Within the T-100 epic, doc 06 wins on everything doc 06 speaks to; doc 03 still wins on general schema conventions, the design README still wins on visual language and the permission table, the Bible still wins wherever it already speaks, and doc 04 stack decisions are not reopened. ## Non-negotiable rules (all agents) 1. One ticket = one branch = one worktree = one PR. Never touch main. diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index be73398..8bc93ee 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -7,6 +7,10 @@ formations.py section 5 formations, keystones, rondo zones, identities (T-004) team_content.py section 4.2/4.3 saved_patterns, boards (T-004) sessions.py section 6 sessions, session_items, session_receipts (T-004) + tactics.py doc 06 section 3 Tactics Lab: formation phases, rotation + systems, position archetypes, archetype combinations, + unit balance rules, formation matchups, team formations + and their slots (T-101) Every name is re-exported here so callers keep writing `from app.models import X` (T-003 routers and deps already do this), and @@ -27,6 +31,16 @@ RoleSynergy, ) from app.models.sessions import SessionItem, SessionReceipt, TrainingSession +from app.models.tactics import ( + ArchetypeCombination, + FormationMatchup, + FormationPhase, + PositionArchetype, + RotationSystem, + TeamFormation, + TeamFormationSlot, + UnitBalanceRule, +) from app.models.team_content import Board, SavedPattern __all__ = [ @@ -50,4 +64,12 @@ "TrainingSession", "SessionItem", "SessionReceipt", + "FormationPhase", + "RotationSystem", + "PositionArchetype", + "ArchetypeCombination", + "UnitBalanceRule", + "FormationMatchup", + "TeamFormation", + "TeamFormationSlot", ] diff --git a/backend/app/models/formations.py b/backend/app/models/formations.py index f4b4f58..cccfc7d 100644 --- a/backend/app/models/formations.py +++ b/backend/app/models/formations.py @@ -3,7 +3,7 @@ team_id anywhere in this module. """ -from sqlalchemy import ForeignKey, JSON, String, Text +from sqlalchemy import ForeignKey, Integer, JSON, String, Text from sqlalchemy.orm import Mapped, mapped_column from app.db import Base @@ -41,12 +41,19 @@ class FormationKeystone(Base): class RondoZone(Base): """Bible 3G.2 rondo map; 3G.1 rondo table seeds the metadata even though the session planner itself is deferred. No id in doc 03; - (formation_code, zone_key) is the natural key.""" + (formation_code, zone_key) is the natural key. + + doc 06 section 3.1 amendment (T-101, migration 0006): three new + columns, plus a data migration that splits the single 4-3-3 + `flank_corridor` row into `flank_corridor_left` and + `flank_corridor_right` so every formation can eventually carry both + flanks instead of one polygon standing in for both sides.""" __tablename__ = "rondo_zones" formation_code: Mapped[str] = mapped_column(ForeignKey("formations.code"), primary_key=True) - # first_line | midfield_box | flank_corridor | last_line | counterpress + # first_line | midfield_box | flank_corridor_left | flank_corridor_right + # | last_line | counterpress zone_key: Mapped[str] = mapped_column(String(30), primary_key=True) polygon_json: Mapped[list] = mapped_column(JSON, nullable=False) rondo_name: Mapped[str] = mapped_column(String(120), nullable=False) @@ -54,6 +61,15 @@ class RondoZone(Base): trains_pattern_codes: Mapped[list] = mapped_column(JSON, nullable=False, default=list) source_ref: Mapped[str | None] = mapped_column(String(60), nullable=True) content_version: Mapped[str | None] = mapped_column(String(20), nullable=True) + # Fallback label when no opposition is placed, e.g. '4v2'. Nullable: + # T-101 adds the column but seeds no new content (T-102/T-103's job), + # and inventing a label for the five still-unseeded formations is a + # content decision this ticket does not own. + canonical_rondo: Mapped[str | None] = mapped_column(String(30), nullable=True) + # polygon | ball_relative_circle. Defaults to 'polygon' because every + # zone seeded so far (and every zone this migration creates) is one. + zone_kind: Mapped[str] = mapped_column(String(30), nullable=False, default="polygon") + radius: Mapped[int | None] = mapped_column(Integer, nullable=True) class Identity(Base): diff --git a/backend/app/models/tactics.py b/backend/app/models/tactics.py new file mode 100644 index 0000000..f27eeb8 --- /dev/null +++ b/backend/app/models/tactics.py @@ -0,0 +1,244 @@ +"""Tactics Lab (Epic T-100, doc 06 section 3): formation phases, rotation +systems, position archetypes and their combinations/balance rules, +formation matchups, and a team's own saved formation setups. + +Two worlds in this one module, same reasoning as roster.py mixing +PositionCode/Role (library) with Player/PlayerAttribute (team) in one +file because they are one ticket's thematic slice: + + - library world, no team_id anywhere: FormationPhase, RotationSystem, + PositionArchetype, ArchetypeCombination, UnitBalanceRule, + FormationMatchup. Seeded, read-only to teams. T-101 creates these + tables empty; T-102/T-103 own the seed content. + - team world: TeamFormation carries team_id directly. + TeamFormationSlot carries no team_id of its own and scopes + transitively through team_formation_id -> team_formations.team_id, + the same shape player_attributes already uses through player_id -> + players.team_id (app/scoped.py TeamScope.query_via). + +Fixed vocabularies (phase, family, rule_kind, severity, route_kind, unit, +zone_kind, foot_hint) are plain strings here, validated at the Pydantic +layer when a route is built on top of these tables (T-108), not as DB +enums or CHECK constraints: same precedent as role_on_team +(app/models/platform.py) and attribute_key (app/models/roster.py). +""" + +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, JSON, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.db import Base +from app.models._util import utcnow + + +# --------------------------------------------------------------------------- +# Library world (doc 06 section 3.1) +# --------------------------------------------------------------------------- + + +class FormationPhase(Base): + """A named shape a base formation morphs into in one phase of play + (doc 06 section 3.1). Natural key (formation_code, variant_code), same + style as formation_keystones. The morph animation binds by slot, so + positions_json's slot set must exactly equal the base formation's slot + set: enforced by the seed validator (T-102/T-103), not here.""" + + __tablename__ = "formation_phases" + + formation_code: Mapped[str] = mapped_column(ForeignKey("formations.code"), primary_key=True) + variant_code: Mapped[str] = mapped_column(String(30), primary_key=True) + # in_possession | out_of_possession | rest_defence | transition + phase: Mapped[str] = mapped_column(String(20), nullable=False) + name: Mapped[str] = mapped_column(String(120), nullable=False) + shape_label: Mapped[str] = mapped_column(String(20), nullable=False) + blurb: Mapped[str] = mapped_column(String(300), nullable=False) + positions_json: Mapped[list] = mapped_column(JSON, nullable=False, default=list) + trigger: Mapped[str] = mapped_column(Text, nullable=False) + # '3+2' | '2+3' | '4+1' | '5+2', nullable per doc 06. + rest_shape: Mapped[str | None] = mapped_column(String(10), nullable=True) + reference_code: Mapped[str | None] = mapped_column( + ForeignKey("identities.code"), nullable=True + ) + uses_rotations: Mapped[list] = mapped_column(JSON, nullable=False, default=list) + source_ref: Mapped[str | None] = mapped_column(String(60), nullable=True) + content_version: Mapped[str | None] = mapped_column(String(20), nullable=True) + + +class RotationSystem(Base): + """A named positional rotation, e.g. a fullback inverting into the + pivot (doc 06 section 3.1). `risk` is required, not nullable: doc 06 + is explicit that "a rotation without a stated cost fails the + validator", so the column itself refuses to be empty even before the + seed validator (T-102/T-103) runs.""" + + __tablename__ = "rotation_systems" + + code: Mapped[str] = mapped_column(String(40), primary_key=True) + name: Mapped[str] = mapped_column(String(120), nullable=False) + # first_line | pivot | wide | front_line + family: Mapped[str] = mapped_column(String(20), nullable=False) + applies_to_formations: Mapped[list] = mapped_column(JSON, nullable=False, default=list) + produces_shape: Mapped[str] = mapped_column(String(20), nullable=False) + trigger: Mapped[str] = mapped_column(Text, nullable=False) + what_moves_json: Mapped[list] = mapped_column(JSON, nullable=False, default=list) + coaching_points_json: Mapped[list] = mapped_column(JSON, nullable=False, default=list) + risk: Mapped[str] = mapped_column(Text, nullable=False) + requires_profile_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + animation_spec_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + exemplar_note: Mapped[str | None] = mapped_column(Text, nullable=True) + source_ref: Mapped[str | None] = mapped_column(String(60), nullable=True) + content_version: Mapped[str | None] = mapped_column(String(20), nullable=True) + + +class PositionArchetype(Base): + """A named player profile within a slot family, e.g. an inverted + fullback (doc 06 section 3.1). `duties_json` is what the unit-balance + checker runs on (UnitBalanceRule); its vocabulary is closed and kept + small by doc 06's own instruction, so adding a duty is a spec change, + not a seed change.""" + + __tablename__ = "position_archetypes" + + code: Mapped[str] = mapped_column(String(40), primary_key=True) + slot_family: Mapped[str] = mapped_column(String(20), nullable=False) + name: Mapped[str] = mapped_column(String(120), nullable=False) + definition: Mapped[str] = mapped_column(Text, nullable=False) + key_attribute_keys: Mapped[list] = mapped_column(JSON, nullable=False, default=list) + # same_side | opposite_side | either, nullable per doc 06. + foot_hint: Mapped[str | None] = mapped_column(String(20), nullable=True) + awr_default: Mapped[str] = mapped_column(String(10), nullable=False) + dwr_default: Mapped[str] = mapped_column(String(10), nullable=False) + duties_json: Mapped[list] = mapped_column(JSON, nullable=False, default=list) + enables_pattern_codes: Mapped[list] = mapped_column(JSON, nullable=False, default=list) + enables_rotation_codes: Mapped[list] = mapped_column(JSON, nullable=False, default=list) + needs_around_it: Mapped[str] = mapped_column(Text, nullable=False) + exemplar_note: Mapped[str | None] = mapped_column(Text, nullable=True) + source_ref: Mapped[str | None] = mapped_column(String(60), nullable=True) + content_version: Mapped[str | None] = mapped_column(String(20), nullable=True) + + +class ArchetypeCombination(Base): + """A named pairing/trio of archetypes within one unit, e.g. a specific + double pivot combination (doc 06 section 3.1). `what_it_costs` is + required, not nullable, the same "state the cost" rule as + RotationSystem.risk.""" + + __tablename__ = "archetype_combinations" + + code: Mapped[str] = mapped_column(String(40), primary_key=True) + # midfield_three | double_pivot | front_three | strike_pair | + # back_line | wide_unit | box_midfield + unit: Mapped[str] = mapped_column(String(30), nullable=False) + name: Mapped[str] = mapped_column(String(120), nullable=False) + slots_json: Mapped[list] = mapped_column(JSON, nullable=False, default=list) + what_it_gives: Mapped[str] = mapped_column(Text, nullable=False) + what_it_costs: Mapped[str] = mapped_column(Text, nullable=False) + reference_note: Mapped[str | None] = mapped_column(Text, nullable=True) + home_formations: Mapped[list] = mapped_column(JSON, nullable=False, default=list) + source_ref: Mapped[str | None] = mapped_column(String(60), nullable=True) + content_version: Mapped[str | None] = mapped_column(String(20), nullable=True) + + +class UnitBalanceRule(Base): + """The generalisation of role_clashes (app/models/roster.py) across a + whole unit rather than a pair (doc 06 section 3.1): "reuse its + evaluation shape so the two engines read alike." `warning_copy` is + coach-facing and must read as a check, not an error, exactly like + RoleClash.warning_copy.""" + + __tablename__ = "unit_balance_rules" + + code: Mapped[str] = mapped_column(String(40), primary_key=True) + unit: Mapped[str] = mapped_column(String(30), nullable=False) + # requires_duty | max_duty | max_same_archetype + rule_kind: Mapped[str] = mapped_column(String(20), nullable=False) + duty: Mapped[str | None] = mapped_column(String(20), nullable=True) + min_count: Mapped[int | None] = mapped_column(Integer, nullable=True) + max_count: Mapped[int | None] = mapped_column(Integer, nullable=True) + warning_copy: Mapped[str] = mapped_column(Text, nullable=False) + # note | warning + severity: Mapped[str] = mapped_column(String(10), nullable=False) + source_ref: Mapped[str | None] = mapped_column(String(60), nullable=True) + content_version: Mapped[str | None] = mapped_column(String(20), nullable=True) + + +class FormationMatchup(Base): + """How one formation attacks another, "the how the ball finds it" + line (doc 06 section 3.1). Natural key (ours_code, theirs_code) with + ours_code <= theirs_code normalised at seed time (T-102/T-103), not + enforced as a DB constraint here, matching the repo's precedent of + keeping ordering/shape rules like this at the seed/validator layer + rather than as schema-level CHECK constraints.""" + + __tablename__ = "formation_matchups" + + ours_code: Mapped[str] = mapped_column(ForeignKey("formations.code"), primary_key=True) + theirs_code: Mapped[str] = mapped_column(ForeignKey("formations.code"), primary_key=True) + our_edges_json: Mapped[list] = mapped_column(JSON, nullable=False, default=list) + their_edges_json: Mapped[list] = mapped_column(JSON, nullable=False, default=list) + route: Mapped[str] = mapped_column(Text, nullable=False) + # through | around | over + route_kind: Mapped[str] = mapped_column(String(10), nullable=False) + source_ref: Mapped[str | None] = mapped_column(String(60), nullable=True) + content_version: Mapped[str | None] = mapped_column(String(20), nullable=True) + + +# --------------------------------------------------------------------------- +# Team world (doc 06 section 3.2) +# --------------------------------------------------------------------------- + + +class TeamFormation(Base): + """A coach's own saved formation setup for their team (doc 06 section + 3.2). Team world, direct team_id, same scoping shape as SavedPattern + and Board (app/models/team_content.py). + + active_phase_variant/opponent_phase_variant are plain vocabulary + strings, not a composite FK into formation_phases(formation_code, + variant_code): doc 06 does not ask for that referential integrity, and + T-101 seeds no formation_phases rows at all, so a composite FK here + would make every team_formation insert depend on content this ticket + deliberately leaves unseeded (T-102/T-103's job). Same reasoning as + FormationMatchup's ours_code<=theirs_code ordering: enforced at the + app/validator layer, not the schema, when that layer exists (T-108).""" + + __tablename__ = "team_formations" + + id: Mapped[int] = mapped_column(primary_key=True) + team_id: Mapped[int] = mapped_column(ForeignKey("teams.id"), nullable=False, index=True) + name: Mapped[str] = mapped_column(String(120), nullable=False) + base_formation_code: Mapped[str] = mapped_column(ForeignKey("formations.code"), nullable=False) + active_phase_variant: Mapped[str] = mapped_column(String(30), nullable=False) + created_by_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=utcnow, nullable=False + ) + opponent_formation_code: Mapped[str | None] = mapped_column( + ForeignKey("formations.code"), nullable=True + ) + opponent_phase_variant: Mapped[str | None] = mapped_column(String(30), nullable=True) + + +class TeamFormationSlot(Base): + """One slot's assignment within a saved team formation (doc 06 section + 3.2). Team world, but no team_id column of its own: scopes + transitively through team_formation_id -> team_formations.team_id, + exactly the pattern player_attributes uses through player_id -> + players.team_id (app/scoped.py TeamScope.query_via). Natural composite + key (team_formation_id, slot): one assignment per slot per saved + formation, same shape as session_receipts' (session_id, + player_user_id).""" + + __tablename__ = "team_formation_slots" + + team_formation_id: Mapped[int] = mapped_column( + ForeignKey("team_formations.id"), primary_key=True + ) + slot: Mapped[str] = mapped_column(String(30), primary_key=True) + player_id: Mapped[int | None] = mapped_column(ForeignKey("players.id"), nullable=True) + archetype_code: Mapped[str | None] = mapped_column( + ForeignKey("position_archetypes.code"), nullable=True + ) + # Coach-declared 1v1 advantage at this slot. + qualitative_edge: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) diff --git a/backend/app/routers/formations.py b/backend/app/routers/formations.py index dfbc26b..e8ff606 100644 --- a/backend/app/routers/formations.py +++ b/backend/app/routers/formations.py @@ -25,8 +25,18 @@ # Bible 3G.2's rondo map order (first-line build-up through to the # counterpress moment); only 433 carries seeded zones today (seeds/ # rondo_zones.json), but the ordering applies to any formation that gains -# a rondo map later. -ZONE_ORDER = ("first_line", "midfield_box", "flank_corridor", "last_line", "counterpress") +# a rondo map later. flank_corridor split into left/right (T-101, +# migration 0006, doc 06 section 3.1); left before right matches the +# repo's own slot-naming convention (formations.json lists every '_l' +# slot before its '_r' counterpart, e.g. fb_l before fb_r). +ZONE_ORDER = ( + "first_line", + "midfield_box", + "flank_corridor_left", + "flank_corridor_right", + "last_line", + "counterpress", +) def _order_index(value: str, order: tuple[str, ...]) -> int: diff --git a/backend/app/scoped.py b/backend/app/scoped.py index 460d894..0355cbf 100644 --- a/backend/app/scoped.py +++ b/backend/app/scoped.py @@ -10,14 +10,26 @@ instead of get_db + a team_id argument has no path by which a client could name another team's id, because there is no such argument. -Two scoping shapes exist per doc 03: +Two scoping shapes exist per doc 03 (and doc 06 section 3.2 for the +Tactics Lab tables T-101 adds on top): - Direct: the table carries team_id itself (players, playstyle_suggestions, - saved_patterns, boards, sessions). Use .query() / .get() / .add(). + saved_patterns, boards, sessions, team_formations). Use .query() / + .get() / .add(). - Transitive: the table has no team_id column and scopes through a parent FK instead (player_attributes -> players, session_items and - session_receipts -> sessions). Use .query_via(). + session_receipts -> sessions, team_formation_slots -> + team_formations). Use .query_via(). This mirrors doc 03's own column lists exactly rather than adding a -team_id doc 03 does not list on those child tables. +team_id doc 03 does not list on those child tables, and doc 06 section 3.2 +explicitly calls for team_formation_slots to scope "transitively through +team_formation_id, same pattern as player_attributes". + +Both query() and query_via() are generic over any mapped model (they read +team_id off the model/parent class via getattr, not a hardcoded table +list), so TeamFormation and TeamFormationSlot need no new methods here, +only the calls above: TeamScope.query(TeamFormation) and +TeamScope.query_via(TeamFormationSlot, TeamFormation, +TeamFormationSlot.team_formation_id == TeamFormation.id). """ from typing import Any, TypeVar diff --git a/backend/migrations/versions/0006_tactics_lab_schema.py b/backend/migrations/versions/0006_tactics_lab_schema.py new file mode 100644 index 0000000..fc2e0f6 --- /dev/null +++ b/backend/migrations/versions/0006_tactics_lab_schema.py @@ -0,0 +1,340 @@ +"""Tactics Lab schema (Epic T-100, doc 06 section 3, T-101): six new +library-world tables (formation_phases, rotation_systems, +position_archetypes, archetype_combinations, unit_balance_rules, +formation_matchups), two new team-world tables (team_formations, +team_formation_slots), and an amendment to the existing rondo_zones table. + +rondo_zones gains three columns (canonical_rondo, zone_kind, radius) and a +data migration: the single seeded 4-3-3 `flank_corridor` row (seeds/ +rondo_zones.json) covers polygon y 75-100, one flank only. Cross- +referencing seeds/formations.json's own slot naming convention for 433 +(fb_l y=12, w_l y=15 vs fb_r y=88, w_r y=85: every '_l' slot sits at low +y, every '_r' slot at high y, per CLAUDE.md rule 8's landscape model +coords) shows y 75-100 is specifically the RIGHT flank corridor, not an +arbitrary single side. This migration therefore: + - keeps that existing polygon, unchanged, as `flank_corridor_right` + - mirrors it across the pitch's y=50 midline (y' = 100 - y, x + unchanged) for a new `flank_corridor_left` row, covering y 0-25 + - carries rondo_name, teaches, trains_pattern_codes, source_ref and + content_version onto BOTH new rows unchanged, per doc 06 section + 3.1's explicit instruction + - deletes the original `flank_corridor` row +Written generically over every formation_code that currently has a +`flank_corridor` row (only 433 today), not hardcoded to '433', so any +future data seeded with the old single-corridor shape before this +migration runs would split correctly too. `downgrade()` reverses the +split from the `_right` side's polygon (the one that existed pre- +migration) before dropping the three new columns. + +Team-world tables (doc 03 section 1, CLAUDE.md rule 4): team_formations +carries team_id directly; team_formation_slots carries none and scopes +transitively through team_formation_id, the same shape player_attributes +already uses through player_id (see app/scoped.py). + +This ticket creates the six new library-world tables empty: seed content +for them is T-102/T-103's job, not this migration's. + +Revision ID: 0006 +Revises: 0005 +Create Date: 2026-08-07 00:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0006" +down_revision: Union[str, None] = "0005" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +# Lightweight Core table, columns only as needed for the data migration +# below, typed so JSON columns serialise/deserialise correctly through +# plain Core select/insert/delete rather than raw textual SQL. +_rondo_zones = sa.table( + "rondo_zones", + sa.column("formation_code", sa.String), + sa.column("zone_key", sa.String), + sa.column("polygon_json", sa.JSON), + sa.column("rondo_name", sa.String), + sa.column("teaches", sa.Text), + sa.column("trains_pattern_codes", sa.JSON), + sa.column("source_ref", sa.String), + sa.column("content_version", sa.String), +) + + +def _mirror_polygon_y(polygon: list[dict]) -> list[dict]: + """Model space is landscape, y 0-100 top to bottom (CLAUDE.md rule 8). + Mirroring across the pitch's y=50 midline (y' = 100 - y, x unchanged) + turns the seeded right-flank corridor into its left-flank + counterpart.""" + return [{"x": point["x"], "y": 100 - point["y"]} for point in polygon] + + +def _split_flank_corridor_rows(conn: sa.engine.Connection) -> None: + existing = conn.execute( + sa.select(_rondo_zones).where(_rondo_zones.c.zone_key == "flank_corridor") + ).fetchall() + + for row in existing: + shared = dict( + rondo_name=row.rondo_name, + teaches=row.teaches, + trains_pattern_codes=row.trains_pattern_codes, + source_ref=row.source_ref, + content_version=row.content_version, + ) + conn.execute( + sa.insert(_rondo_zones).values( + formation_code=row.formation_code, + zone_key="flank_corridor_right", + polygon_json=row.polygon_json, + **shared, + ) + ) + conn.execute( + sa.insert(_rondo_zones).values( + formation_code=row.formation_code, + zone_key="flank_corridor_left", + polygon_json=_mirror_polygon_y(row.polygon_json), + **shared, + ) + ) + + conn.execute(sa.delete(_rondo_zones).where(_rondo_zones.c.zone_key == "flank_corridor")) + + +def _merge_flank_corridor_rows(conn: sa.engine.Connection) -> None: + """Downgrade path: for every formation_code carrying both split rows, + recreate the single flank_corridor row from the _right side (the + polygon that existed before upgrade() ran), then drop both split + rows. zone_kind/canonical_rondo/radius are deliberately left out of + the recreated row's values so the still-present zone_kind column + falls back to its own server default rather than this function + hardcoding a value that duplicates that default.""" + right_rows = conn.execute( + sa.select(_rondo_zones).where(_rondo_zones.c.zone_key == "flank_corridor_right") + ).fetchall() + + for row in right_rows: + conn.execute( + sa.insert(_rondo_zones).values( + formation_code=row.formation_code, + zone_key="flank_corridor", + polygon_json=row.polygon_json, + rondo_name=row.rondo_name, + teaches=row.teaches, + trains_pattern_codes=row.trains_pattern_codes, + source_ref=row.source_ref, + content_version=row.content_version, + ) + ) + + conn.execute( + sa.delete(_rondo_zones).where( + _rondo_zones.c.zone_key.in_(["flank_corridor_left", "flank_corridor_right"]) + ) + ) + + +def upgrade() -> None: + # --- library world (doc 06 section 3.1) --------------------------- + op.create_table( + "position_archetypes", + sa.Column("code", sa.String(length=40), primary_key=True), + sa.Column("slot_family", sa.String(length=20), nullable=False), + sa.Column("name", sa.String(length=120), nullable=False), + sa.Column("definition", sa.Text(), nullable=False), + sa.Column("key_attribute_keys", sa.JSON(), nullable=False), + sa.Column("foot_hint", sa.String(length=20), nullable=True), + sa.Column("awr_default", sa.String(length=10), nullable=False), + sa.Column("dwr_default", sa.String(length=10), nullable=False), + sa.Column("duties_json", sa.JSON(), nullable=False), + sa.Column("enables_pattern_codes", sa.JSON(), nullable=False), + sa.Column("enables_rotation_codes", sa.JSON(), nullable=False), + sa.Column("needs_around_it", sa.Text(), nullable=False), + sa.Column("exemplar_note", sa.Text(), nullable=True), + sa.Column("source_ref", sa.String(length=60), nullable=True), + sa.Column("content_version", sa.String(length=20), nullable=True), + ) + + op.create_table( + "rotation_systems", + sa.Column("code", sa.String(length=40), primary_key=True), + sa.Column("name", sa.String(length=120), nullable=False), + sa.Column("family", sa.String(length=20), nullable=False), + sa.Column("applies_to_formations", sa.JSON(), nullable=False), + sa.Column("produces_shape", sa.String(length=20), nullable=False), + sa.Column("trigger", sa.Text(), nullable=False), + sa.Column("what_moves_json", sa.JSON(), nullable=False), + sa.Column("coaching_points_json", sa.JSON(), nullable=False), + sa.Column("risk", sa.Text(), nullable=False), + sa.Column("requires_profile_json", sa.JSON(), nullable=True), + sa.Column("animation_spec_json", sa.JSON(), nullable=True), + sa.Column("exemplar_note", sa.Text(), nullable=True), + sa.Column("source_ref", sa.String(length=60), nullable=True), + sa.Column("content_version", sa.String(length=20), nullable=True), + ) + + op.create_table( + "formation_phases", + sa.Column("formation_code", sa.String(length=10), primary_key=True), + sa.Column("variant_code", sa.String(length=30), primary_key=True), + sa.Column("phase", sa.String(length=20), nullable=False), + sa.Column("name", sa.String(length=120), nullable=False), + sa.Column("shape_label", sa.String(length=20), nullable=False), + sa.Column("blurb", sa.String(length=300), nullable=False), + sa.Column("positions_json", sa.JSON(), nullable=False), + sa.Column("trigger", sa.Text(), nullable=False), + sa.Column("rest_shape", sa.String(length=10), nullable=True), + sa.Column("reference_code", sa.String(length=40), nullable=True), + sa.Column("uses_rotations", sa.JSON(), nullable=False), + sa.Column("source_ref", sa.String(length=60), nullable=True), + sa.Column("content_version", sa.String(length=20), nullable=True), + sa.ForeignKeyConstraint( + ["formation_code"], + ["formations.code"], + name="fk_formation_phases_formation_code_formations", + ), + sa.ForeignKeyConstraint( + ["reference_code"], + ["identities.code"], + name="fk_formation_phases_reference_code_identities", + ), + ) + + op.create_table( + "archetype_combinations", + sa.Column("code", sa.String(length=40), primary_key=True), + sa.Column("unit", sa.String(length=30), nullable=False), + sa.Column("name", sa.String(length=120), nullable=False), + sa.Column("slots_json", sa.JSON(), nullable=False), + sa.Column("what_it_gives", sa.Text(), nullable=False), + sa.Column("what_it_costs", sa.Text(), nullable=False), + sa.Column("reference_note", sa.Text(), nullable=True), + sa.Column("home_formations", sa.JSON(), nullable=False), + sa.Column("source_ref", sa.String(length=60), nullable=True), + sa.Column("content_version", sa.String(length=20), nullable=True), + ) + + op.create_table( + "unit_balance_rules", + sa.Column("code", sa.String(length=40), primary_key=True), + sa.Column("unit", sa.String(length=30), nullable=False), + sa.Column("rule_kind", sa.String(length=20), nullable=False), + sa.Column("duty", sa.String(length=20), nullable=True), + sa.Column("min_count", sa.Integer(), nullable=True), + sa.Column("max_count", sa.Integer(), nullable=True), + sa.Column("warning_copy", sa.Text(), nullable=False), + sa.Column("severity", sa.String(length=10), nullable=False), + sa.Column("source_ref", sa.String(length=60), nullable=True), + sa.Column("content_version", sa.String(length=20), nullable=True), + ) + + op.create_table( + "formation_matchups", + sa.Column("ours_code", sa.String(length=10), primary_key=True), + sa.Column("theirs_code", sa.String(length=10), primary_key=True), + sa.Column("our_edges_json", sa.JSON(), nullable=False), + sa.Column("their_edges_json", sa.JSON(), nullable=False), + sa.Column("route", sa.Text(), nullable=False), + sa.Column("route_kind", sa.String(length=10), nullable=False), + sa.Column("source_ref", sa.String(length=60), nullable=True), + sa.Column("content_version", sa.String(length=20), nullable=True), + sa.ForeignKeyConstraint( + ["ours_code"], ["formations.code"], name="fk_formation_matchups_ours_code_formations" + ), + sa.ForeignKeyConstraint( + ["theirs_code"], + ["formations.code"], + name="fk_formation_matchups_theirs_code_formations", + ), + ) + + # --- team world (doc 06 section 3.2) ------------------------------- + op.create_table( + "team_formations", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("team_id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=120), nullable=False), + sa.Column("base_formation_code", sa.String(length=10), nullable=False), + sa.Column("active_phase_variant", sa.String(length=30), nullable=False), + sa.Column("created_by_user_id", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("opponent_formation_code", sa.String(length=10), nullable=True), + sa.Column("opponent_phase_variant", sa.String(length=30), nullable=True), + sa.ForeignKeyConstraint( + ["team_id"], ["teams.id"], name="fk_team_formations_team_id_teams" + ), + sa.ForeignKeyConstraint( + ["base_formation_code"], + ["formations.code"], + name="fk_team_formations_base_formation_code_formations", + ), + sa.ForeignKeyConstraint( + ["created_by_user_id"], + ["users.id"], + name="fk_team_formations_created_by_user_id_users", + ), + sa.ForeignKeyConstraint( + ["opponent_formation_code"], + ["formations.code"], + name="fk_team_formations_opponent_formation_code_formations", + ), + ) + op.create_index("ix_team_formations_team_id", "team_formations", ["team_id"]) + + op.create_table( + "team_formation_slots", + sa.Column("team_formation_id", sa.Integer(), primary_key=True), + sa.Column("slot", sa.String(length=30), primary_key=True), + sa.Column("player_id", sa.Integer(), nullable=True), + sa.Column("archetype_code", sa.String(length=40), nullable=True), + sa.Column("qualitative_edge", sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint( + ["team_formation_id"], + ["team_formations.id"], + name="fk_team_formation_slots_team_formation_id_team_formations", + ), + sa.ForeignKeyConstraint( + ["player_id"], ["players.id"], name="fk_team_formation_slots_player_id_players" + ), + sa.ForeignKeyConstraint( + ["archetype_code"], + ["position_archetypes.code"], + name="fk_team_formation_slots_archetype_code_position_archetypes", + ), + ) + + # --- rondo_zones amendment (doc 06 section 3.1) -------------------- + with op.batch_alter_table("rondo_zones") as batch_op: + batch_op.add_column(sa.Column("canonical_rondo", sa.String(length=30), nullable=True)) + batch_op.add_column( + sa.Column("zone_kind", sa.String(length=30), nullable=False, server_default="polygon") + ) + batch_op.add_column(sa.Column("radius", sa.Integer(), nullable=True)) + + _split_flank_corridor_rows(op.get_bind()) + + +def downgrade() -> None: + _merge_flank_corridor_rows(op.get_bind()) + + with op.batch_alter_table("rondo_zones") as batch_op: + batch_op.drop_column("radius") + batch_op.drop_column("zone_kind") + batch_op.drop_column("canonical_rondo") + + op.drop_table("team_formation_slots") + op.drop_index("ix_team_formations_team_id", table_name="team_formations") + op.drop_table("team_formations") + op.drop_table("formation_matchups") + op.drop_table("unit_balance_rules") + op.drop_table("archetype_combinations") + op.drop_table("formation_phases") + op.drop_table("rotation_systems") + op.drop_table("position_archetypes") diff --git a/backend/tests/test_formations_routes.py b/backend/tests/test_formations_routes.py index 7938989..9ff4d79 100644 --- a/backend/tests/test_formations_routes.py +++ b/backend/tests/test_formations_routes.py @@ -5,7 +5,8 @@ via the real scripts/seed.py loader (same in-process import convention as test_seed_content.py's idempotency test) rather than hand-built fixtures, so this exercises the actual seeded content: 6 formations, 13 keystones, -5 rondo zones (all on 433, per seeds/rondo_zones.json's own note). +6 rondo zones (all on 433, per seeds/rondo_zones.json's own note; 6 not 5 +since T-101/migration 0006 split flank_corridor into left/right). """ import importlib.util @@ -111,7 +112,14 @@ def test_rondo_zones_show_their_rondo_and_linked_patterns(client: TestClient) -> formations = {f["code"]: f for f in coach.get("/api/formations").json()} f433 = formations["433"] zones = {z["zone_key"]: z for z in f433["rondo_zones"]} - assert set(zones) == {"first_line", "midfield_box", "flank_corridor", "last_line", "counterpress"} + assert set(zones) == { + "first_line", + "midfield_box", + "flank_corridor_left", + "flank_corridor_right", + "last_line", + "counterpress", + } midfield = zones["midfield_box"] assert midfield["rondo_name"] == "5v3 (the midfield box)" diff --git a/backend/tests/test_migrations.py b/backend/tests/test_migrations.py index ae8a5cb..0d5baf8 100644 --- a/backend/tests/test_migrations.py +++ b/backend/tests/test_migrations.py @@ -14,13 +14,14 @@ design. """ +import json import pathlib import tempfile import pytest from alembic import command from alembic.config import Config -from sqlalchemy import create_engine, inspect +from sqlalchemy import create_engine, inspect, text import app.models # noqa: F401 (registers every table on Base.metadata) from app.db import Base @@ -88,3 +89,168 @@ def test_migration_chain_has_no_gaps_or_branches() -> None: heads = script.get_heads() assert len(heads) == 1, f"expected exactly one migration head, found {heads}" + + +def _insert_formation_433(conn) -> None: + """Minimal valid formations row (revision 0002's shape), enough to + satisfy rondo_zones.formation_code's FK without pulling in the real + seed content this ticket does not own.""" + conn.execute( + text( + "INSERT INTO formations " + "(code, name, shape_blurb, strengths_json, vulnerabilities_json, " + "natural_identities, positions_json, source_ref, content_version) " + "VALUES (:code, :name, :shape_blurb, :strengths_json, " + ":vulnerabilities_json, :natural_identities, :positions_json, " + ":source_ref, :content_version)" + ), + { + "code": "433", + "name": "4-3-3", + "shape_blurb": "test", + "strengths_json": json.dumps([]), + "vulnerabilities_json": json.dumps([]), + "natural_identities": json.dumps([]), + "positions_json": json.dumps([]), + "source_ref": "bible:test", + "content_version": "1.0.0", + }, + ) + + +def _insert_pre_0006_flank_corridor_row(conn) -> None: + """A rondo_zones row in the exact pre-migration-0006 shape (no + canonical_rondo/zone_kind/radius columns yet), standing in for a real + deploy's already-seeded 4-3-3 flank_corridor row (seeds/ + rondo_zones.json) before this ticket's data migration runs.""" + conn.execute( + text( + "INSERT INTO rondo_zones " + "(formation_code, zone_key, polygon_json, rondo_name, teaches, " + "trains_pattern_codes, source_ref, content_version) " + "VALUES (:formation_code, :zone_key, :polygon_json, :rondo_name, " + ":teaches, :trains_pattern_codes, :source_ref, :content_version)" + ), + { + "formation_code": "433", + "zone_key": "flank_corridor", + "polygon_json": json.dumps( + [{"x": 20, "y": 75}, {"x": 90, "y": 75}, {"x": 90, "y": 100}, {"x": 20, "y": 100}] + ), + "rondo_name": "2v1 to 2v2 (the flank corridor)", + "teaches": "Winger and fullback against their fullback.", + "trains_pattern_codes": json.dumps(["A1", "A2", "F1"]), + "source_ref": "bible:3G.2", + "content_version": "1.0.0", + }, + ) + + +def test_flank_corridor_row_split_upgrades_an_existing_populated_db(fresh_db_url: str) -> None: + """Platform DoD: the migration chain must upgrade an existing + POPULATED DB cleanly, not just build a fresh one. Builds a DB up to + 0005 (pre-Tactics-Lab), inserts a formations row and a rondo_zones row + in the exact shape a real, already-seeded deploy would have, then + upgrades to head (0006) and proves the data migration actually ran: + the single flank_corridor row becomes flank_corridor_left (mirrored + across y=50) and flank_corridor_right (the original polygon, + unchanged), both carrying the original row's rondo_name, teaches, + trains_pattern_codes, source_ref and content_version untouched, and + the original flank_corridor row is gone.""" + cfg = _alembic_config() + command.upgrade(cfg, "0005") + + engine = create_engine(fresh_db_url) + with engine.begin() as conn: + _insert_formation_433(conn) + _insert_pre_0006_flank_corridor_row(conn) + engine.dispose() + + command.upgrade(cfg, "head") + + engine = create_engine(fresh_db_url) + with engine.connect() as conn: + rows = { + row.zone_key: row + for row in conn.execute( + text( + "SELECT zone_key, polygon_json, rondo_name, teaches, " + "trains_pattern_codes, source_ref, content_version, " + "canonical_rondo, zone_kind, radius " + "FROM rondo_zones WHERE formation_code = '433'" + ) + ).fetchall() + } + engine.dispose() + + assert "flank_corridor" not in rows + assert set(rows) >= {"flank_corridor_left", "flank_corridor_right"} + + right = rows["flank_corridor_right"] + assert json.loads(right.polygon_json) == [ + {"x": 20, "y": 75}, {"x": 90, "y": 75}, {"x": 90, "y": 100}, {"x": 20, "y": 100}, + ] + + left = rows["flank_corridor_left"] + assert json.loads(left.polygon_json) == [ + {"x": 20, "y": 25}, {"x": 90, "y": 25}, {"x": 90, "y": 0}, {"x": 20, "y": 0}, + ] + + for zone_key in ("flank_corridor_left", "flank_corridor_right"): + row = rows[zone_key] + assert row.rondo_name == "2v1 to 2v2 (the flank corridor)" + assert row.teaches == "Winger and fullback against their fullback." + assert json.loads(row.trains_pattern_codes) == ["A1", "A2", "F1"] + assert row.source_ref == "bible:3G.2" + assert row.content_version == "1.0.0" + assert row.zone_kind == "polygon" + assert row.canonical_rondo is None + assert row.radius is None + + +def test_flank_corridor_row_split_downgrades_back_to_one_row(fresh_db_url: str) -> None: + """The reverse of the split test above: upgrading to head then + downgrading all the way back to 0005 must merge flank_corridor_left + and flank_corridor_right back into a single flank_corridor row (using + the _right side's polygon, the one that existed pre-migration) and + must drop the three new rondo_zones columns.""" + cfg = _alembic_config() + command.upgrade(cfg, "0005") + + engine = create_engine(fresh_db_url) + with engine.begin() as conn: + _insert_formation_433(conn) + _insert_pre_0006_flank_corridor_row(conn) + engine.dispose() + + command.upgrade(cfg, "head") + command.downgrade(cfg, "0005") + + engine = create_engine(fresh_db_url) + with engine.connect() as conn: + columns = {c["name"] for c in inspect(engine).get_columns("rondo_zones")} + rows = conn.execute( + text( + "SELECT zone_key, polygon_json, rondo_name, teaches, " + "trains_pattern_codes, source_ref, content_version " + "FROM rondo_zones WHERE formation_code = '433'" + ) + ).fetchall() + engine.dispose() + + assert columns == { + "formation_code", "zone_key", "polygon_json", "rondo_name", "teaches", + "trains_pattern_codes", "source_ref", "content_version", + } + + by_key = {row.zone_key: row for row in rows} + assert set(by_key) == {"flank_corridor"} + merged = by_key["flank_corridor"] + assert json.loads(merged.polygon_json) == [ + {"x": 20, "y": 75}, {"x": 90, "y": 75}, {"x": 90, "y": 100}, {"x": 20, "y": 100}, + ] + assert merged.rondo_name == "2v1 to 2v2 (the flank corridor)" + assert merged.teaches == "Winger and fullback against their fullback." + assert json.loads(merged.trains_pattern_codes) == ["A1", "A2", "F1"] + assert merged.source_ref == "bible:3G.2" + assert merged.content_version == "1.0.0" diff --git a/backend/tests/test_scoped_query_layer.py b/backend/tests/test_scoped_query_layer.py index 48d8bd7..73395cf 100644 --- a/backend/tests/test_scoped_query_layer.py +++ b/backend/tests/test_scoped_query_layer.py @@ -2,11 +2,12 @@ has team scoping; a cross-team read attempt in tests returns nothing." Exercises app/scoped.py's TeamScope directly against two teams' worth of -content, for every team-scoped table in doc 03: the five that carry -team_id directly (Player, PlaystyleSuggestion, SavedPattern, Board, -TrainingSession) via .query()/.get(), and the three that scope -transitively through a parent FK (PlayerAttribute, SessionItem, -SessionReceipt) via .query_via(). For each table: team A's scope sees +content, for every team-scoped table in doc 03 plus doc 06 section 3.2's +Tactics Lab additions (T-101): the six that carry team_id directly +(Player, PlaystyleSuggestion, SavedPattern, Board, TrainingSession, +TeamFormation) via .query()/.get(), and the four that scope transitively +through a parent FK (PlayerAttribute, SessionItem, SessionReceipt, +TeamFormationSlot) via .query_via(). For each table: team A's scope sees only team A's row, team B's scope sees only team B's row, and a direct id guess across teams (.get()) returns None rather than the other team's row. """ @@ -19,6 +20,7 @@ from app.db import SessionLocal from app.models import ( Board, + Formation, Player, PlayerAttribute, PlaystyleSuggestion, @@ -26,6 +28,8 @@ SessionItem, SessionReceipt, Team, + TeamFormation, + TeamFormationSlot, TeamMember, TrainingSession, User, @@ -221,6 +225,41 @@ def test_sessions_cross_team_read_returns_nothing( assert scope_b.get(TrainingSession, session_a.id) is None +def test_team_formations_cross_team_read_returns_nothing( + db: Session, two_teams: tuple[Team, User, Team, User] +) -> None: + """doc 06 section 3.2 (T-101): team_formations carries team_id + directly, same scoping shape as saved_patterns/boards/sessions.""" + team_a, coach_a, team_b, coach_b = two_teams + db.add(Formation(code="433", name="4-3-3", shape_blurb="test", positions_json=[])) + db.flush() + + tf_a = TeamFormation( + team_id=team_a.id, + name="Formation A", + base_formation_code="433", + active_phase_variant="in_possession", + created_by_user_id=coach_a.id, + ) + tf_b = TeamFormation( + team_id=team_b.id, + name="Formation B", + base_formation_code="433", + active_phase_variant="in_possession", + created_by_user_id=coach_b.id, + ) + db.add_all([tf_a, tf_b]) + db.commit() + + scope_a = TeamScope(db=db, team_id=team_a.id) + scope_b = TeamScope(db=db, team_id=team_b.id) + + assert {f.name for f in scope_a.query(TeamFormation).all()} == {"Formation A"} + assert {f.name for f in scope_b.query(TeamFormation).all()} == {"Formation B"} + assert scope_a.get(TeamFormation, tf_b.id) is None + assert scope_b.get(TeamFormation, tf_a.id) is None + + # --------------------------------------------------------------------------- # Transitively-scoped tables (no team_id column; scope through a parent FK) # --------------------------------------------------------------------------- @@ -353,6 +392,66 @@ def test_session_receipts_cross_team_read_returns_nothing( ) +def test_team_formation_slots_cross_team_read_returns_nothing( + db: Session, two_teams: tuple[Team, User, Team, User] +) -> None: + """doc 06 section 3.2 (T-101): team_formation_slots carries no + team_id of its own, scoping transitively through team_formation_id -> + team_formations.team_id, the same shape player_attributes already + uses through player_id -> players.team_id.""" + team_a, coach_a, team_b, coach_b = two_teams + db.add(Formation(code="433", name="4-3-3", shape_blurb="test", positions_json=[])) + db.flush() + + tf_a = TeamFormation( + team_id=team_a.id, + name="Formation A", + base_formation_code="433", + active_phase_variant="in_possession", + created_by_user_id=coach_a.id, + ) + tf_b = TeamFormation( + team_id=team_b.id, + name="Formation B", + base_formation_code="433", + active_phase_variant="in_possession", + created_by_user_id=coach_b.id, + ) + db.add_all([tf_a, tf_b]) + db.flush() + + db.add_all( + [ + TeamFormationSlot(team_formation_id=tf_a.id, slot="gk"), + TeamFormationSlot(team_formation_id=tf_b.id, slot="gk"), + ] + ) + db.commit() + + scope_a = TeamScope(db=db, team_id=team_a.id) + scope_b = TeamScope(db=db, team_id=team_b.id) + + slots_a = scope_a.query_via( + TeamFormationSlot, TeamFormation, TeamFormationSlot.team_formation_id == TeamFormation.id + ).all() + slots_b = scope_b.query_via( + TeamFormationSlot, TeamFormation, TeamFormationSlot.team_formation_id == TeamFormation.id + ).all() + + assert {s.team_formation_id for s in slots_a} == {tf_a.id} + assert {s.team_formation_id for s in slots_b} == {tf_b.id} + assert ( + scope_a.query_via( + TeamFormationSlot, + TeamFormation, + TeamFormationSlot.team_formation_id == TeamFormation.id, + ) + .filter(TeamFormationSlot.team_formation_id == tf_b.id) + .first() + is None + ) + + # --------------------------------------------------------------------------- # The scoped layer itself: enforcement primitives # --------------------------------------------------------------------------- diff --git a/backend/tests/test_seed_content.py b/backend/tests/test_seed_content.py index dbf492c..5eeaddc 100644 --- a/backend/tests/test_seed_content.py +++ b/backend/tests/test_seed_content.py @@ -280,7 +280,7 @@ def table_counts(session) -> dict[str, int]: "library_items": 23, "formations": 6, "formation_keystones": 13, - "rondo_zones": 5, + "rondo_zones": 6, # T-101/migration 0006 split flank_corridor into left/right "identities": 27, } diff --git a/docs/agent/BACKLOG.md b/docs/agent/BACKLOG.md index ccb5d51..5793d36 100644 --- a/docs/agent/BACKLOG.md +++ b/docs/agent/BACKLOG.md @@ -30,3 +30,27 @@ Model: sonnet default; opus = hard ticket, never downgrade. Sequencing: T-001 solo → (T-002 ∥ T-003 ∥ T-004) → (T-010/011 ∥ T-020/021/022) → screens fan-out → collab → phone → hardening → deploy. Board engine (T-020..022) is the critical path and the hardest work: start it immediately after T-001, keep it isolated (Brief §4 Phase 2 note). + +--- + +## Epic T-100: Tactics Lab (founder commission 2026-08-07) + +Source of truth: `docs/source/06_Tactical_Depth_Spec.md`. That doc wins on everything in this epic; doc 03 still wins on schema conventions, the design README on visual language and permissions, the Bible wherever it already speaks (1, 2, 3G, 4, 5B). +Dispatch rule for this epic: give a subagent its ticket row plus **only** the doc 06 sections its row names. Nothing more. + +| ID | Title | Doc 06 §§ | Agent | Model | Deps | Parallel-safe with | Status | +|---|---|---|---|---|---|---|---| +| T-100 | Scope amendment: move 4 rows to IN in Brief §1, add doc 06 to the CLAUDE.md source table, no code | 0 | platform | sonnet | none | none (solo, first) | done | +| T-101 | Schema + Alembic: formation_phases, rotation_systems, position_archetypes, archetype_combinations, unit_balance_rules, formation_matchups, rondo_zones new columns + L/R corridor data migration, team_formations, team_formation_slots; scoped layer for both team-world tables + cross-team read test | 3 | platform | sonnet | T-100 | T-104 | done | +| T-102 | Seeds: position_archetypes (all 10 slot families), archetype_combinations, unit_balance_rules; validator extensions (duty vocabulary closed, key_attribute_keys subset of the six, cost line required) | 2.6, 3.1 | content-seeder | opus | T-101 | T-103, T-104 | todo | +| T-103 | Seeds: formation_phases (6 formations x 3-5 variants), rotation_systems (14 incl. animation specs), rondo_zones for all 6 formations at 6 zones, formation_matchups (15 pairs), 10 reference systems as identities kind=reference_system; validator: phase slot-set equality, risk line required | 2.3, 2.4, 2.5, 2.8, 3.1 | content-seeder | opus | T-101 | T-102, T-104 | todo | +| T-104 | Superiority engine: mirrorOpponent (involutive round-trip test FIRST), pointInPolygon/Circle, countZone, findFreeMen, gridOccupancy, classifyRestDefence, buildRead + route inference; recompute benchmark under 2ms at 22 tokens | 2.2, 4 | board-engineer | opus | T-100 | T-101, T-102, T-103 | done | +| T-105 | Phase morph playback (bind by slot, 600ms) + opponent token layer mirrored into our frame, both reusing the existing animation player and PatternPreviewBoard, no parallel renderer | 4, 5.1 | board-engineer | opus | T-104, T-103 | T-108 | todo | +| T-106 | Formations page rebuild: phase segment, opposition toggle + opponent pickers, live rondo counts, rotation player with equal-weight risk line, positional grid overlay, portrait pass | 5.1, 5.2, 5.4 | screens | opus | T-105 | T-107 | todo | +| T-107 | Personnel panel: slot assignment from roster, archetype picker, ranked suggestions with cited reasons, live unit balance, footedness notes (all coach-only), empty-roster state | 2.6, 2.7, 5.3 | screens | sonnet | T-105, T-102 | T-106 | todo | +| T-108 | API: /formations/{code}/phases, /formations/matchup, /rotations, /archetypes, /archetypes/suggest, team formation persistence; 403 for player tokens on every coach-only route, test per route | 3.2, 5.3, 6 | collab | sonnet | T-101 | T-105 | todo | +| T-109 | Epic hardening: em-dash sweep over new seeds, permission suite additions in CI, tactics-lab Playwright journey at both viewports, extend the demo path | 6 | verifier | sonnet | T-106, T-107, T-108 | none | todo | + +Sequencing: T-100 solo → T-101 ∥ T-104 → (T-102 ∥ T-103 ∥ T-108) → T-105 → (T-106 ∥ T-107) → T-109. +T-104 is this epic's critical path and its hardest work, same reasoning as T-020: coordinate math, unit tests first, keep it isolated from the seed tickets. +T-102 and T-103 are marked opus despite being seed work: the football judgement in the archetype duty assignments and the rotation risk lines is the product, not transcription. diff --git a/docs/source/02_MVP_Implementation_Brief.md b/docs/source/02_MVP_Implementation_Brief.md index 7895097..74674af 100644 --- a/docs/source/02_MVP_Implementation_Brief.md +++ b/docs/source/02_MVP_Implementation_Brief.md @@ -24,7 +24,7 @@ | Deliveries library, F1-F8 tiles + details | PNG 29 | Bible 3F.0 vocabulary, F1-F8 entries | IN | | Rotations library: R1, R12, R13 | PNG 30, 31 | Bible 5B.1, 5B.5 | IN | | Formations: 6 presets, keystones, details | PNG 11, 19, 37-39, 43 | Bible Section 4 | IN | -| Rondo Map overlay (5 zones) | PNG 32, 36 | Bible 3G.2 | IN | +| Rondo Map overlay (6 zones, all formations) | PNG 32, 36; doc 06 §0 | Bible 3G.2 | IN, all formations, 6 zones (left and right flank corridors split) | | Roster: roles, AWR/DWR, 6 sliders | PNG 12, 20 | Bible 1.2, 1.3, Section 2 | IN | | Fit warning: double-exposure flank, coach-only | PNG 12 | Bible 2B.3 Flank Balance, 2B.4 | IN (this warning only) | | Player playstyle suggestion + coach review | PNG 24, 25, 27 | README roles table | IN | @@ -34,14 +34,17 @@ | Coach / Player permission model | README table, role toggle | README principles | IN, exactly as specified | | Phone layouts, portrait boards, coordinate mapping | PNG 14-20, 23, 28, 34-36, 43-45; README formula | README | IN | | Synergy glow on whiteboard | Not in any screen | Bible 2B.1, 8.1 suggests it | OUT of UI; synergy data seeded | -| Full clash warning set | Only double-exposure designed | Bible 2B.4 | OUT beyond the designed warning; data seeded | +| Full clash warning set | Only double-exposure designed; doc 06 §0 designs the unit-balance surface | Bible 2B.4 | IN for unit-balance warnings only; other Bible 2B.4 clashes stay OUT | | Identity-shifted fit thresholds | No surface | Bible 5.7 table | OUT; profile data seeded | -| Formation matchup overlay | No surface | Bible 3G.3 | OUT; matchup data seeded | -| F14-F16, crossing selectors, Four-Run Box, auto footedness | No surface | Bible 3F | OUT; F5 rule ships as card copy only | +| Formation matchup overlay | Doc 06 §0 (epic designs the surface) | Bible 3G.3 | IN; opposition toggle plus live per-zone superiority on the Formations board | +| F14-F16, crossing selectors, Four-Run Box | No surface | Bible 3F | OUT; F5 rule ships as card copy only | +| Auto footedness | Doc 06 §0 (epic designs the surface) | Bible 3F | IN; derives from players.preferred_foot, already in roster data | | National styles (S7), Canada card | No surface | Bible 7 | OUT; may seed as inactive data | | Training-session / drill planner, rondo auto-suggest | No surface | Bible 3G.1 | OUT; rondo-to-concept mapping seeded | | Club layer, video, payments, parent view | No surface | PRD later phases | OUT | +**Founder decision (2026-08-07, approved by the founder):** auto footedness, the formation matchup overlay, and unit-balance clash warnings move from OUT to IN; the Rondo Map overlay scope expands to all six formations and six zones (flank corridors split left and right). Bible 2B.4's other clashes, F14-F16, crossing selectors, and the Four-Run Box stay OUT. See `docs/source/06_Tactical_Depth_Spec.md` §0 for the full amendment and the epic that builds it. + --- ## 2. The strict copy requirement diff --git a/docs/source/06_Tactical_Depth_Spec.md b/docs/source/06_Tactical_Depth_Spec.md new file mode 100644 index 0000000..9e374b2 --- /dev/null +++ b/docs/source/06_Tactical_Depth_Spec.md @@ -0,0 +1,496 @@ +# Patterns of Play, Tactical Depth Spec (Formations Lab) +### Source of truth for the T-100 epic. Written 2026-08-07 by the orchestrator, commissioned by the founder. +**Version:** 1.0 +**Status:** binding for T-100..T-109. Amends the MVP Brief scope table (see Section 0). + +**Conflict rule (extends CLAUDE.md):** this doc wins on everything in the T-100 epic: the formation phase model, rotation systems, position archetypes, footedness, the superiority engine, and the Formations page surface. Doc 03 still wins on general schema conventions (scoping, source_ref, content_version, natural keys). The design handoff README still wins on visual language and the permission table. The Bible still wins wherever it already says something (Sections 1, 2, 3G, 4, 5B are the seed spine for this epic). Doc 04 stack decisions are not reopened. + +**Reading order for an implementing agent:** Section 0 (what changed in scope), then only the sections your ticket names. + +--- + +## 0. Scope amendment + +The MVP Brief §1 scope table marked four rows OUT because they had no designed surface. This epic builds the surface, so the founder has moved them IN. T-100 amends the Brief table itself; no other ticket may rely on this section without T-100 merged. + +| Brief §1 row | Was | Now | Why | +|---|---|---|---| +| Formation matchup overlay | OUT, data seeded | **IN** | This epic designs the surface: opposition toggle plus live per-zone superiority on the Formations board. | +| Rondo Map overlay (5 zones) | IN, 4-3-3 only | **IN, all formations, 6 zones** | Left and right flank corridors split, because asymmetry is the point of every modern shape. | +| Auto footedness (Bible 3F) | OUT | **IN** | `players.preferred_foot` already exists and is unused. The footedness engine is pure derivation from data the roster already holds. | +| Full clash warning set | OUT beyond double-exposure | **IN for unit-balance warnings only** | Archetype combination balance is the core mechanic of this epic. Bible 2B.4's other clashes stay OUT. | + +Still OUT, unchanged, and no ticket in this epic may build them: training session planner, drill scheduling, video, club layer, national styles, opponent scouting import, live match tooling. + +**Scope discipline note.** This epic adds tactical depth to one page. It does not add a new product pillar. If a ticket finds itself designing a session planner, a match report, or an opponent database, it has left scope: stop and write the question in the PR body per CLAUDE.md rule 7. + +--- + +## 1. What we are building, in one paragraph + +The Formations page stops being a shape browser and becomes a **tactics lab**. A coach picks a base shape, then watches it morph into what it actually looks like with the ball (3-2-5, 2-3-5, box midfield) and without it (4-4-2 mid block, 5-4-1 low block). The coach drops an opponent shape on top and the pitch immediately answers the only question that matters: **where are we spare, where are we short, and which route connects them.** The coach plays a named rotation (the inverted fullback, the stepping centre back, the pivot drop) and sees the shape reorganise. The coach assigns real roster players into slots, picks an archetype for each, and the app tells them which combinations balance and which leave nobody holding. Every number on the screen is computed from coordinates, not authored, so it stays true when the coach changes anything. + +--- + +## 2. Football content register + +This is the football. Every item here is editorial reference, never prescription. The existing copy rule from the design README holds without exception: **identities and reference systems curate, never lock.** Phrase every reference the way `seeds/roles.json` already does ("not a licence: names are editorial reference points only"). + +### 2.1 The three superiorities (the spine of the whole epic) + +Already in Bible 3G. Restated because the engine computes against it: + +- **Numerical**: more bodies than them in a zone. Computed from token counts. +- **Positional**: same numbers, better placed. The free man between the lines. Computed from the JdP grid: a player alone in a half-space between two opponent lines. +- **Qualitative**: a 1v1 our player simply wins. Not computable, coach-declared per slot (a toggle on the personnel panel), which is honest and keeps us out of fake-analytics territory. + +Every card the engine emits must name which superiority it is talking about. That is the transferable coaching language and it is what separates this from a formation picker. + +### 2.2 Positional play grid (juego de posición) + +The pitch divides into **5 vertical lanes** (left wing, left half-space, centre, right half-space, right wing) and **5 horizontal lines** (own third build, first line, middle, between the lines, last line). Half-spaces carry priority: they are where the danger comes from because a player there can see and be seen by both the flank and the centre. + +Occupancy rules to enforce as live warnings, held as guidelines and never as errors: +- No more than **three** teammates on any horizontal line. +- No more than **two** teammates in any vertical lane. +- Wide lanes: **one** occupant each, whenever possible. +- Circulate **between** zones, not within a zone. Diagonal forward passes are preferred to straight vertical ones. +- Temporary breaches are legal when forming a triangle, creating an overload, or dragging a marker out. The player returns afterwards. So the UI copy is "check this" not "wrong". + +Lane boundaries (model coords, y is 0 at top to 100 at bottom, per CLAUDE.md rule 8): + +| Lane | y range | +|---|---| +| Left wing | 0 to 19 | +| Left half-space | 19 to 37 | +| Centre | 37 to 63 | +| Right half-space | 63 to 81 | +| Right wing | 81 to 100 | + +Horizontal line boundaries (x is 0 at own goal to 100 at the attacking goal): + +| Line | x range | +|---|---| +| Own build | 0 to 22 | +| First line | 22 to 42 | +| Middle | 42 to 60 | +| Between the lines | 60 to 78 | +| Last line | 78 to 100 | + +These numbers are the contract. Any ticket changing them changes the seeds too, and both move together in one PR. + +### 2.3 Rondo map, extended to six zones and every formation + +Bible 3G.2 gave five zones on the 4-3-3 only. This epic ships six zones on all six formations. The polygons are seeded per formation because a back three's first line is geometrically different from a back four's. **The ratio label is never seeded when the opposition is on; it is computed.** Seeded `canonical_rondo` is the fallback shown in no-opposition mode. + +| zone_key | What it is | Canonical rondo (no opposition) | Trains | +|---|---|---|---| +| `first_line` | Keeper plus the back line plus whoever drops in, against their first pressing line | 4v2 or 3v2 | B5, B6, B8 | +| `midfield_box` | The central engine room between both midfield lines | 5v3 | A5, B8, B2 | +| `flank_corridor_left` | Left touchline plus left half-space, from own third to the byline | 2v1 to 2v2 | A1, A2, F1 | +| `flank_corridor_right` | Mirror of the above | 2v1 to 2v2 | A1, A2, F1 | +| `last_line` | Our forwards against their back line | 2v2 plus keeper | R12, C3 | +| `counterpress_ring` | A radius around the ball at the moment of loss, in their half | 4v4 plus 3 | D2, D4, C1 | + +The counterpress ring is not a fixed polygon. It is a circle of radius 18 model units centred on the ball's position (or on the centroid of our three most advanced players when no ball is placed). It moves. That is the whole teaching point: rest defence is relative to the ball, not to the pitch. + +### 2.4 Formation phases: in shape and out of shape + +A **phase variant** is the same eleven slots at different coordinates. Slots never change identity across phases, which is what makes the morph animation legible: the coach watches *their left back* walk into midfield, not a token teleport. + +Ship these per base formation. Reference-team variants are additional rows, attributed. + +**4-3-3** +- `in_possession` **3-2-5, single inverted fullback.** One fullback tucks into the pivot beside the six. Other fullback stays as the third of a back three. Wingers hold the touchline, both eights occupy the half-spaces at the last line. Rest defence 3+2. +- `in_possession_alt` **2-3-5, both fullbacks advanced.** Centre backs split wide, six drops to make a line of three with both fullbacks, five across the front. Rest defence 2+3. Higher risk, higher width. +- `out_of_possession` **4-1-4-1 high block.** Nine leads the press, wingers pin the fullbacks, eights step, six screens. +- `out_of_possession_alt` **4-4-2 mid block.** One eight steps beside the nine, the shape becomes two banks of four. +- `rest_defence` 3+2 or 2+3, follows the in-possession variant chosen. + +**4-2-3-1** +- `in_possession` **2-3-5 via a splitting double pivot.** One pivot drops between the centre backs, the other holds. Fullbacks push to the last line. Ten and both wingers occupy the three central and half-space slots between the lines. +- `in_possession_alt` **3-2-5 via one inverted fullback**, double pivot stays intact, the shape most similar to City's. +- `out_of_possession` **4-4-2 mid block**, ten joins the nine. The cleanest defensive conversion in football and the reason this shape is the most used in Europe. +- `out_of_possession_alt` **4-2-3-1 high press**, ten man-marks their pivot. + +**4-4-2** +- `in_possession` **2-4-4 / 4-2-4**, wide midfielders advance, both centre midfielders hold. Structurally short in the centre, which is the honest teaching: the shape's answer is to go over or around, not through. +- `out_of_possession` **4-4-2 flat mid block**, the reference defensive shape. +- `out_of_possession_alt` **4-4-2 low block**, lines within 25 model units of the goal. +- `rest_defence` 4+2. + +**3-5-2** +- `in_possession` **3-2-5**, wing backs to the last line, one of the three midfielders drops beside the pivot, two strikers plus two wing backs plus one arriving eight make five. +- `in_possession_alt` **asymmetric back four**, one wing back high and one deep, the wide centre back sliding to fullback. This is the Inter shape. +- `out_of_possession` **5-3-2**, wing backs drop into a back five. +- `rest_defence` 3+2, the most natural in football. + +**3-4-3** +- `in_possession` **3-2-5**, the purest version: back three holds, double pivot holds, wing backs and front three make five. Wing backs are the entire width. +- `in_possession_alt` **3-2-2-3 box midfield**, the two wide forwards drop into the half-spaces to form the top of a box with the double pivot at its base. +- `out_of_possession` **5-4-1** or **5-2-3** press. +- `rest_defence` 3+2. + +**5-4-1 / 5-3-2** +- `in_possession` **3-4-3 on the break**, wing backs launch, the shape is only briefly a five. +- `out_of_possession` **5-4-1 low block**, the reference park-the-bus shape. +- `rest_defence` 5+2, which is really "we are not attacking with numbers, and that is the plan". + +### 2.5 Rotation systems: the named library + +These are **structural** rotations (who changes job), distinct from the existing library rotations R1, R12, R13 (which are movement patterns). Each ships with an animation spec so it plays on the board using the existing player, and each names what it costs, not only what it gives. A rotation with no stated risk is marketing, not coaching. + +| code | Name | Produces | Who moves | Trigger | What it costs | +|---|---|---|---|---|---| +| `rot_invert_fb_pivot` | Inverted fullback into the pivot | 3-2-5 | One fullback steps inside beside the six | Goal kick or centre-back circulation against a two-striker press | The flank behind him is empty on the turnover. Needs a winger who defends or a wide centre back who can cover the channel. | +| `rot_invert_fb_high` | Fullback into the eight line | 3-1-6 | Fullback steps inside and forward to the height of the eights | Opponent block already pinned deep, we need bodies between the lines | Only one screener behind six attackers. This is a lead-chasing shape, not a default. | +| `rot_cb_step` | Centre back steps into midfield | 3-2-5 or 2-3-5 | A centre back carries or steps into the pivot line | Their first line refuses to press, so the free man must come from the back | If he is caught stepping, the back line is a two against their front two. | +| `rot_cb_invert_middle` | Middle centre back of a three inverts | 2-3-5 from a back three | The central defender of the back three steps in front of the other two | Keeper is under pressure and needs a bounce option that does not exist wide | Loses the spare central defender against a lone striker who plays on the shoulder. | +| `rot_pivot_drop` | Salida lavolpiana, pivot drops between the centre backs | 3-2 build | The six drops between the split centre backs | Two strikers pressing the two centre backs, we need a third | Removes the screen in front of the back line. If it is played badly the counter goes straight through the vacated space. | +| `rot_double_pivot_split` | One of the double pivot drops, the other holds | 3-2-5 | One pivot drops between centre backs, the partner stays as the single screen | The press arrives in a 4-4-2 and the two centre backs are 2v2 | The remaining pivot is alone against two eights. | +| `rot_wb_asymmetry` | One wing back high, one deep | Back four in build, five in attack | Ball-far wing back drops to the back line, ball-near wing back holds the last line | Building down one side against a back four | The deep wing back is the only cover on his entire flank. | +| `rot_fb_touchline_swap` | Winger inside, fullback outside | 2-3-5 with inverted wingers | Winger takes the half-space, fullback takes the touchline | Their fullback is tucking narrow to protect the centre back | The winger is no longer isolated in a 1v1, so the qualitative superiority is traded for a positional one. Know which one you wanted. | +| `rot_false_nine_drop` | Nine drops, wingers dive the channels | 4-2-4 shape in the moment | Nine drops between the lines, both wingers run the channels he vacated | The ball reaches a facing midfielder | Nobody occupies the centre backs. If the runs do not go, the last line has zero pin. | +| `rot_box_form` | Two forwards drop to form the box midfield | 3-2-2-3 | Both wide forwards or both eights take the half-space slots between the lines | We are winning the centre and want to keep it | Wide zones are empty except the wing backs. Two exhausted wing backs is a real cost. | +| `rot_press_bait_hold` | Hold the ball dead to invite the presser | Build shape unchanged | Centre back stops the ball with the sole, faces forward, waits | Opponent's first line is hesitating on the edge of pressing | It is a genuine risk taken on purpose in our own third. Only run it with players who can execute under pressure, and say so. | +| `rot_gk_plus_one` | Keeper as the spare man in the build | 3-2 with the keeper as the apex | Keeper steps to the edge of the box and becomes the free man of the first-line rondo | The opponent presses with one more than we build with | Everything behind the keeper is empty. The single highest-consequence rotation in the book. | +| `rot_ten_drop_pivot` | Ten drops beside the pivot | 4-3-3 from a 4-2-3-1 | The ten drops into the midfield line to make a three | Their ten or striker is screening our pivot and we cannot get out | We surrender the between-the-lines occupant, which is the shape's whole point. Temporary only. | +| `rot_overload_isolate` | Overload one side, switch to the isolated winger | Shape unchanged, occupancy shifted | Six or seven players commit to one flank, the far winger stays wide and alone | We have a winger who wins his 1v1 | The switch must be prepared, not hopeful. An unprepared long diagonal is a turnover in our own build shape. | + +**Reference systems** (attributed, editorial, one line of provenance each). These bind a phase variant plus a set of rotations to a named modern side. They belong in the `identities` table as `kind = 'reference_system'`, alongside the existing reference teams, so they inherit the existing "curate never lock" copy handling. + +1. **Manchester City, 3-2-5 with the inverting centre back.** A 4-3-3 on the team sheet that becomes a back three plus a two-man pivot, with a centre back stepping into midfield to make the pivot rather than a fullback. Front five pins the back four. Rest defence 3+2. Teaches: the free man can be manufactured from any line, and the position it comes from decides who covers the counter. +2. **Arsenal, 3-2-5 into 3-1-6.** One fullback tucks to complete a back three while the other steps into the pivot, freeing both central midfielders to attack the half-spaces as dual eights. Redundancy is the point: either fullback can be the inverter, which makes the shape robust to a marking scheme. +3. **Bayer Leverkusen and Real Madrid, 3-4-2-1 into 3-2-5.** The back three and the double pivot both stay, and the entire width plus the top of the attack comes from two wing backs. The build forms square structures in midfield so the carrier always has two forward options. Teaches: width and goal threat can be the same two players, if you have those two players. +4. **Liverpool, 4-2-3-1 into 2-3-5.** Centre backs split, both fullbacks advance, one pivot drops and one steps. Compact 4-3-3 without the ball. Teaches: the double pivot is a mechanism, not a position pair. +5. **Brighton under De Zerbi, press baiting into 2-4-4.** Centre backs hold the ball dead with the sole of the foot to keep every lane open and invite the first presser. The backward pass is a trigger, not a retreat. Fullbacks invert beside the pivot. Teaches: pressure is information, and you can choose when to receive it. +6. **Inter, 3-5-2 with asymmetric wing backs.** One wing back high and one deep turns a back three into a back four in build and a front five in attack, with heavy rotation in possession and a rigid 5-3-2 without it. Teaches: fluid with the ball and rigid without it is a coherent model, not a contradiction. +7. **Amorim's 3-4-3, inverting the middle centre back.** The central defender of the three steps in front to give the keeper a bounce option and to manipulate the opponent's first line. Out of possession the shape becomes 5-4-1 or 4-4-2. Teaches: the inverter does not have to be a fullback. +8. **Como under Fàbregas, 4-2-3-1 with a splitting pivot.** The double pivot splits to create a back-three illusion, inverted wingers occupy the half-spaces while fullbacks own the touchline, high line around 42 metres. Teaches: possession is a means of controlling space, not a statistic. +9. **The low-block counter, 5-4-1 into 3-4-3.** The block exists to make the pitch small, and the outlet striker is the most important defender on the team. Teaches: the rest of the epic in reverse, because every superiority above is one this shape is deliberately conceding. +10. **Barcelona's high line as rest defence.** Instead of leaving bodies behind the ball, leave none and hold an extreme offside line. Teaches: rest defence is a philosophy with more than one answer, and this one has a specific, nameable failure mode. + +Each reference system card carries: base formation, phase variant it produces, rotations used, keystone profiles required, one youth takeaway, and one honest risk line. No card may exceed the existing blurb limits enforced by the seed validator. + +### 2.6 Position archetypes + +An **archetype** is finer than a role. Existing `roles` answers "how does this player play the position". An archetype answers "which specific job does this player do inside a unit, and what does the unit then need around him". Archetypes attach to a **slot family**, not a position code, because the eight in a 4-3-3 and the eight in a 3-5-2 are different jobs. + +Slot families: `gk`, `cb_central`, `cb_wide`, `fb`, `wb`, `six`, `eight`, `ten`, `wide_forward`, `nine`. + +Every archetype row carries: code, slot family, name, one-sentence definition, 2 to 3 `key_attribute_keys` drawn strictly from the existing six (`pace`, `passing_range`, `carrying_1v1`, `positional_discipline`, `aerial_physical`, `pressing_engine`), a foot hint, AWR/DWR defaults, `enables_pattern_codes`, `enables_rotation_codes`, `needs_around_it` (free text, one line), and `exemplar_note` with the standing disclaimer. + +**The eight, worked in full, because it is the archetype family the founder called out.** + +| code | Name | Key attributes | Job | Needs around it | +|---|---|---|---|---| +| `eight_half_space_creator` | Half-space creator | passing_range, positional_discipline | Receives between their midfield and back line in the half-space and plays the pass that beats the last line | A six who holds, and a winger who pins the fullback so the half-space stays open | +| `eight_box_crasher` | Box crasher | pace, aerial_physical | Arrives late and unmarked at the far post or the penalty spot, the third-man finisher | Someone else holding the middle, because he will not be there when the ball turns over | +| `eight_carrier` | Line-breaking carrier | carrying_1v1, pace | Breaks the line by driving through it rather than passing through it | Space to run into, so pair him with players who pin rather than drop | +| `eight_ball_winner` | Ball winner | pressing_engine, positional_discipline | The counterpress trigger of the midfield, wins the ball back five seconds after we lose it | A creator alongside, or the trio has no forward pass | +| `eight_deep_rotator` | Deep rotator | passing_range, positional_discipline | Drops beside the six to make a temporary double pivot, then leaves once the line is broken | A partner who does the opposite, otherwise both drop and nobody occupies | +| `eight_wide_rotator` | Wide rotator | pace, carrying_1v1 | Takes the touchline when the fullback inverts, so the width never disappears | An inverting fullback. Without one this archetype is just a bad winger | + +**Combination rules for a midfield three (six plus two eights).** These are the mechanic. Ship as `archetype_combinations` rows with a computed check: + +- Exactly one archetype in the trio must own **tempo** (`six_metronome`, `six_line_breaker`, or `eight_deep_rotator`). Zero means the ball never circulates cleanly. Two means neither accelerates. +- At least one must own **progression** (`eight_half_space_creator` or `eight_carrier`). Without it the trio recycles and never breaks. +- At least one must own **rest defence** (`six_destroyer`, `six_metronome`, `eight_ball_winner`, or `eight_deep_rotator`). Without it the 3+2 is a 3+1 and the counter arrives free. +- Two `eight_box_crasher` is the classic imbalance: both arrive, nobody holds. Warn, do not block. Some coaches want exactly this when chasing a game, and the warning should say so. +- Two `eight_half_space_creator` warns for the mirror reason: nobody wins it back. + +Named good combinations to seed, with what each gives and costs: +- **Metronome, creator, crasher.** The positional-possession trio. Controls, unlocks, finishes. Costs: the crasher's flank is exposed on the turnover. +- **Destroyer, carrier, ball winner.** The gegenpress trio. Wins it high, drives at them. Costs: limited against a low block, because carrying into a packed box is not a plan. +- **Line breaker, deep rotator, box crasher.** The double-pivot-by-rotation trio. Costs: demands very high tactical discipline about who drops. +- **Metronome, ball winner, half-space creator.** The tournament trio, balanced in all three duties. Costs: no one drives, so it depends on the front three for the last twenty metres. + +Apply the same three-duty framework to the other units, which the seeding ticket writes out in full: +- **Double pivot**: one controller plus one destroyer or runner. Two of the same profile halves the shape's value, which the Bible already says in 4.2 and which becomes a computed warning here. +- **Front three**: needs at least one who pins the last line, at least one who wins a 1v1, and no more than one who drops. Three droppers means the back four is never occupied. +- **Strike pair**: runner plus target, runner plus runner (requires a ten or a long-ball identity), false plus poacher. +- **Back line**: at least one who steps and one who covers. Two steppers means the space behind is permanently open. Two coverers means we never regain the ball high. +- **Wide unit (fullback plus wide forward)**: exactly one takes the touchline. Both inside means no width, both outside means no half-space occupant. This generalises the existing double-exposure flank warning rather than replacing it. + +### 2.7 Footedness engine + +`players.preferred_foot` already exists (`L`, `R`, `B`) and is currently decorative. Derive these, all from foot plus assigned slot side. All are one-line coach-facing notes, never blocking. + +1. **Left centre back, right-footed.** Closed body shape. His first pass points back inside, and a presser who shades him infield takes half the pitch away. A left-footed left centre back opens the body to the whole field. +2. **Wide forward, opposite-footed to his side.** Inside forward profile: cuts in to shoot, and the touchline is available for an overlapping fullback. Same-footed: touchline profile, holds width, delivers early. This changes which delivery types (F codes) are actually on his menu, so surface it next to the delivery library links. +3. **Wing back, same-footed to his side.** Natural early cross and out-swinging delivery. Opposite-footed: cutback and inside combination, in-swinging delivery. +4. **Both fullbacks inverting on the same foot.** The pivot receives from the same angle every time and becomes predictable to press. Flag it. +5. **Deliveries.** Opposite-footed delivery from a flank in-swings, same-footed out-swings. Attach this to the existing F1 to F8 library items so a coach picking a delivery sees which of their players can actually hit it. +6. **`B` (two-footed)** suppresses every warning above for that slot and says so, because two-footedness is a genuine tactical asset and should read as one. + +Coach-only, per the permission table: these are fit information and never render in a player view. + +### 2.8 Formation matchups + +Bible 3G.3 has six matchup rows already. Extend to the fifteen unordered pairs of the six MVP shapes, plus the reference systems as pseudo-opponents (a 4-4-2 mid block is a different opponent from a 4-4-2 high press, and the coach should be able to pick which). Every matchup card teaches the same three-step read, and the copy must follow it literally: + +1. **Where is our spare man.** Engine computes it, card names the route to reach him. +2. **Where are we short.** Engine computes it, card names what it costs us. +3. **Which route connects them**: through, around, or over. + +The engine produces step 1 and step 2 numerically for any pair, seeded or not. The seeded card adds the coached read. When a pair has no seeded card, render the computed numbers plus the generic three-step scaffold, and say plainly that this pair has no coached read yet. Do not invent one. + +--- + +## 3. Data model + +Conventions inherited from doc 03 and non-negotiable: library-world tables carry no `team_id`; every seeded row carries `source_ref` and `content_version`; natural composite keys where doc 03's precedent uses them; fixed vocabularies validated at the Pydantic layer, not as DB enums. + +### 3.1 Library world, new tables + +**`formation_phases`** primary key `(formation_code, variant_code)` +``` +formation_code FK formations.code +variant_code str e.g. 'in_possession', 'in_possession_alt', 'out_of_possession', + 'out_of_possession_alt', 'rest_defence' +phase str in_possession | out_of_possession | rest_defence | transition +name str '3-2-5 (inverted left back)' +shape_label str '3-2-5' +blurb text <= 25 words, validator-enforced like every other blurb +positions_json json [{slot, position_code, x, y}] same slot ids as the base formation, all eleven +trigger text when this shape appears +rest_shape str '3+2' | '2+3' | '4+1' | '5+2' | null +reference_code str nullable, FK identities.code +uses_rotations json [rotation_system.code] +source_ref, content_version +``` +Hard validator rule: `positions_json` slot set must be **exactly equal** to the base formation's slot set. A phase that adds, drops, or renames a slot is a seed error, because the morph animation binds by slot. + +**`rotation_systems`** primary key `code` +``` +code, name, family (first_line|pivot|wide|front_line) +applies_to_formations json [formation_code] +produces_shape str +trigger text +what_moves_json json [{slot, from:{x,y}, to:{x,y}, becomes: 'pivot'|'third_cb'|...}] +coaching_points_json json [str] +risk text REQUIRED, not nullable. A rotation without a stated cost fails the validator. +requires_profile_json json {slot: {archetypes:[...], foot: 'L'|'R'|null, attributes:[...]}} +animation_spec_json json same schema the library rotations already use +exemplar_note text with the standing disclaimer +source_ref, content_version +``` + +**`position_archetypes`** primary key `code` +``` +code, slot_family, name, definition +key_attribute_keys json subset of the six, 2 to 3 entries, validator-checked against position_codes vocabulary +foot_hint str nullable: 'same_side' | 'opposite_side' | 'either' +awr_default, dwr_default +duties_json json subset of ['tempo','progression','rest_defence','width','pin','box_threat','press_trigger'] +enables_pattern_codes json +enables_rotation_codes json +needs_around_it text one line +exemplar_note text +source_ref, content_version +``` +`duties_json` is what the combination checker runs on. Keep the duty vocabulary closed and small; adding a duty is a spec change, not a seed change. + +**`archetype_combinations`** primary key `code` +``` +code, unit ('midfield_three'|'double_pivot'|'front_three'|'strike_pair'|'back_line'|'wide_unit'|'box_midfield') +name +slots_json json [{slot_family, archetype_code}] +what_it_gives text +what_it_costs text REQUIRED +reference_note text +home_formations json +source_ref, content_version +``` + +**`unit_balance_rules`** primary key `code` +``` +code, unit +rule_kind 'requires_duty' | 'max_duty' | 'max_same_archetype' +duty str nullable +min_count, max_count int nullable +warning_copy text coach-facing, must read as a check not an error +severity 'note' | 'warning' +source_ref, content_version +``` +This is the generalisation of the existing `role_clashes` mechanic and should reuse its evaluation shape so the two engines read alike. + +**`formation_matchups`** primary key `(ours_code, theirs_code)` with `ours_code <= theirs_code` normalised at seed time +``` +ours_code, theirs_code FK formations.code +our_edges_json json [str] +their_edges_json json [str] +route text the "how the ball finds it" line +route_kind 'through' | 'around' | 'over' +source_ref, content_version +``` + +**`rondo_zones`** gains columns +``` +canonical_rondo str '4v2', fallback label when no opposition is placed +zone_kind 'polygon' | 'ball_relative_circle' +radius int nullable, used when zone_kind is ball_relative_circle +``` +and gains rows for all six formations, with `flank_corridor` split into `flank_corridor_left` and `flank_corridor_right`. Existing 4-3-3 rows migrate: the single `flank_corridor` row becomes two. Handle it as a data migration in the same Alembic revision, not as a seed-only change, so an existing deploy upgrades cleanly. + +### 3.2 Team world, new tables + +**`team_formations`** (team world, direct `team_id`) +``` +id, team_id, name, base_formation_code, active_phase_variant, created_by_user_id, created_at +opponent_formation_code nullable +opponent_phase_variant nullable +``` + +**`team_formation_slots`** (team world, scopes transitively through `team_formation_id`, same pattern as `player_attributes`) +``` +team_formation_id (FK, part of PK), slot (part of PK) +player_id nullable FK players.id +archetype_code nullable FK position_archetypes.code +qualitative_edge bool default false coach-declared 1v1 advantage +``` + +Every query on both goes through `app/scoped.py`. Client input never supplies `team_id`. That is CLAUDE.md rule 4 and there is no exception for this epic. + +--- + +## 4. The superiority engine + +Pure, deterministic, framework-free. Lives in `frontend/src/board/` alongside `coords.ts` and `zones.ts`, and follows the T-020 precedent: **unit tests are written first**, including the round-trip test for the mirror. + +```ts +// All inputs and outputs in landscape model coords (CLAUDE.md rule 8). + +type Pt = { x: number; y: number }; +type SlotPos = { slot: string; position_code: string; x: number; y: number }; + +// 1. Opponent placement. A 180 degree rotation about the pitch centre, so +// their attacking direction is the reverse of ours. Involutive: +// mirrorOpponent(mirrorOpponent(p)) === p, exactly, for all integer inputs. +mirrorOpponent(p: Pt): Pt // { x: 100 - p.x, y: 100 - p.y } + +// 2. Geometry. +pointInPolygon(p: Pt, poly: Pt[]): boolean // ray casting, boundary counts as inside +pointInCircle(p: Pt, centre: Pt, r: number): boolean + +// 3. Per-zone counts. +type ZoneCount = { + zoneKey: string; + ours: number; + theirs: number; + delta: number; // ours - theirs + label: string; // '4v2' + verdict: 'superiority' | 'parity' | 'inferiority'; + superiorityKind: 'numerical' | 'positional' | null; +}; +countZone(zone, ours: SlotPos[], theirs: SlotPos[]): ZoneCount + +// 4. Positional superiority. A free man is one of ours alone in a lane cell +// between two of their horizontal lines, with no opponent in the same cell +// and none within `pressRadius` (default 8 model units). +findFreeMen(ours, theirs, pressRadius = 8): { slot: string; cell: GridCell; whyItMatters: string }[] + +// 5. JdP grid occupancy. Returns breaches, never blocks. +type GridBreach = { kind: 'lane_over' | 'line_over' | 'wide_lane_shared'; cell: string; count: number; slots: string[] }; +gridOccupancy(ours: SlotPos[]): { occupancy: Record; breaches: GridBreach[] } + +// 6. Rest defence classification. Counts our players behind the ball line +// (or behind the centroid of our three most advanced, when no ball is set) +// and splits them into last line and screen line by a 12-unit x gap. +classifyRestDefence(ours: SlotPos[], ballX: number): { shape: string; behindBall: number; lastLine: number; screen: number } + +// 7. The read. Assembles the three-step coaching read from the above. +type MatchupRead = { + spare: ZoneCount | null; // highest positive delta, tie broken toward our own goal + short: ZoneCount | null; // lowest negative delta, tie broken toward our own goal + route: 'through' | 'around' | 'over'; + seededCard: FormationMatchup | null; +}; +buildRead(zones: ZoneCount[], seeded: FormationMatchup | null): MatchupRead +``` + +**Route inference when no card is seeded**, stated so it is reproducible: if `midfield_box.delta > 0` the route is `through`; else if either flank corridor has `delta > 0` the route is `around`; else `over`. Show the inferred route with visibly softer language than a seeded card's route line, and label it as inferred. + +**Performance.** Recompute on every drag frame with 22 tokens and 6 zones. That is well inside the 60fps budget the board already meets at 23 tokens (T-020), but it must be measured, not assumed: add a benchmark test asserting a full recompute stays under 2ms. + +**Determinism.** No randomness, no `Date.now()`, no floating-point-sensitive comparisons in verdicts. Counts are integers, so verdicts are exact. + +--- + +## 5. The Formations page + +Keep the board-first shell that T-032 established: board, floating meta bar, page-level swipe-up sheet. Do not build a parallel renderer, do not fork `PatternPreviewBoard`. Extend. + +### 5.1 Meta bar, four controls + +1. **Phase.** Segmented: Base / With the ball / Without the ball / Rest defence. Selecting one morphs the board over 600ms using the existing animation player, binding by slot. A caption strip under the board names the resulting shape and its trigger. +2. **Opposition.** Off by default. On reveals an opponent formation picker and an opponent phase picker (their out-of-possession variants are the ones that matter, so default the picker there). Opponent tokens render in the opponent colour the board already defines for recorded opponents, mirrored via `mirrorOpponent`. +3. **Rondo map.** Same toggle as today, but each zone now carries a live count chip. With opposition off, the chip shows the seeded `canonical_rondo` in muted styling. With opposition on, the chip shows the computed ratio, coloured by verdict, and the zone card gains the computed read. The counterpress ring only renders when a ball is placed or a phase with a defined ball position is active. +4. **Rotations.** A list filtered to rotations that apply to the current formation. Selecting one plays it on the board and opens a card: trigger, what moves, coaching points, and the risk line given equal visual weight to the benefit. The risk line is not a footnote. + +Only one of Rondo map, Rotations, and Grid may be open at once, matching the existing mutual-exclusion behaviour in `FormationsPage.tsx`. + +### 5.2 Positional grid overlay + +A fifth toggle, off by default: draws the 5 by 5 grid and marks breaches. Breach copy is a check, never an error: "Three in the left half-space. Intentional overload, or is someone standing in a teammate's zone?" Never "invalid". + +### 5.3 Personnel panel + +Opens from the sheet as a third segment. For each of the eleven slots: assigned player (from the team roster), archetype picker, and a suggestion list. + +**Suggested archetypes** rank by, in order: the player's six attribute values against the archetype's `key_attribute_keys`; foot fit against `foot_hint` and the slot's side; AWR/DWR match; and whether the resulting unit passes its balance rules. Show the top three with a one-line why for each. The why must cite the actual reason ("passing range 5 and positional discipline 4 fit the metronome"), not a score. + +**Unit balance** evaluates `unit_balance_rules` live as archetypes change and renders notes and warnings inline under the unit. Coach-only, both in the UI and at the API. A player token requesting the balance endpoint gets 403, and there is a test for it. + +Empty roster is a first-class state: the panel still works with archetypes alone and no players assigned, because a coach planning a shape at 11pm does not want to fill in a roster first. + +### 5.4 Phone portrait + +Everything above renders portrait on phone per the existing formula (`left = y`, `top = 100 - x`). Specific decisions: +- Meta bar controls collapse to an icon row; each opens a bottom sheet rather than a popover. +- Zone count chips shrink to the ratio only, and the read moves entirely into the tapped zone card. +- The grid overlay is available but off by default on phone, because five lanes on a 7:10 board is dense. +- The personnel panel is a full-height sheet, one unit at a time. + +No feature is desktop-only. If something genuinely cannot work portrait, that is a question for the PR body, not a silent omission. + +--- + +## 6. Definition of done + +Every ticket in this epic, in addition to the Brief §5 lines for its workstream: + +1. `make verify` green: lint, typecheck, pytest, vitest, e2e at both viewports, em-dash scan, seed validator. +2. No em dash in any seed file, blurb, coaching point, warning, or label. Applies to every reference-team name and every risk line. +3. Every new team-world query goes through `app/scoped.py`. A cross-team read test returns nothing. +4. Coach-only data (unit balance warnings, footedness notes, archetype suggestions) returns 403 for a player token, tested per endpoint. +5. Engine functions have unit tests written before the implementation, including: mirror round-trip exactness, point-in-polygon boundary cases, a zone count fixture per formation pair, and the recompute benchmark. +6. Every seeded row has `source_ref` and `content_version`. Reference-system rows carry the editorial disclaimer. +7. Every rotation and every combination has a non-empty cost or risk line. The validator enforces it. +8. Phase `positions_json` slot sets match their base formation exactly. Validator-enforced. +9. Playwright journey at both viewports for the ticket's surface, per `.claude/skills/verify-ui`. + +--- + +## 7. Sources + +Research consulted 2026-08-07 for the reference systems in 2.5 and the principles in 2.2 and 2.7. Listed so a later content revision can re-verify rather than re-guess. + +- Coaches' Voice, *The tactical evolution of Pep Guardiola's Manchester City*: https://learning.coachesvoice.com/cv/pep-guardiola-man-city-tactics-2016-2026/ +- Premier League, *Guardiola's seven innovations that revolutionised Premier League tactics*: https://www.premierleague.com/en/news/4663968/pep-guardiolas-seven-innovations-that-revolutionised-premier-league-tactics +- Breaking The Lines, *What is Juego de Posición?*: https://breakingthelines.com/tactical-analysis/what-is-juego-de-posicion/ +- Coaches' Voice, *Positional play: football tactics explained*: https://learning.coachesvoice.com/cv/positional-play-football-tactics-explained-guardiola-cruyff-manchester-city/ +- The Football Analyst, *Box Midfield, Football Tactics Explained*: https://the-footballanalyst.com/box-midfield-football-tactics-explained/ +- Coaches' Voice, *What is rest defence?*: https://learning.coachesvoice.com/cv/rest-defence-explained/ +- The Football Analyst, *Rest-Defence, Football Tactics Explained*: https://the-footballanalyst.com/rest-defence-football-tactics-explained/ +- SoccerTutor, *Xabi Alonso Tactics and Formation, 3-2-5 Style of Play at Leverkusen*: https://www.soccertutor.com/blogs/inside-football-coaching/xabi-alonso-tactics-bayer-leverkusen-3-2-5-attacking-shape-wing-back-threat +- SoccerTutor, *De Zerbi Tactics and Style of Play, How to Bait the Press and Build Up*: https://www.soccertutor.com/blogs/inside-football-coaching/de-zerbis-tactics-bait-the-press-build-up-play +- Arsenal Station, *Arsenal's Tactical Evolution: Inverted Fullbacks, the "Eight," and Standards*: https://www.arsenalstation.com/2025/09/16/arsenals-tactical-evolution-inverted-fullbacks-the-eight-and-standards/ +- Total Football Analysis, *Arne Slot Tactics At Liverpool 2024/25*: https://totalfootballanalysis.com/head-coach-analysis/arne-slot-liverpool-202425-tactical-analysis-tactics +- Total Football Analysis, *Simone Inzaghi 3-5-2 Tactics At Inter Milan 2024/25*: https://totalfootballanalysis.com/head-coach-analysis/simone-inzaghi-inter-tactics-202425-tactical-analysis +- Total Football Analysis, *Rúben Amorim Man United Build-Up Tactics*: https://totalfootballanalysis.com/team-analysis/ruben-amorim-manchester-united-tactics-build-up-tactical-analysis +- Total Football Analysis, *Cesc Fàbregas Tactics At Como 2025/26*: https://totalfootballanalysis.com/data-analysis/cesc-fabregas-tactics-como-2025-2026-data-analysis +- FourFourTwo, *Numerical superiority: football tactics explained*: https://www.fourfourtwo.com/features/numerical-superiority-football-tactics-explained +- Trace, *Trace Toolkit: The Art of the Rondo*: https://traceup.com/academy/trace-toolkit-the-art-of-the-rondo +- ESPN, *Why does every club want a left-footed centre-back?*: https://www.espn.com/soccer/story/_/id/37633113/why-does-every-club-want-sign-left-footed-centre-back +- Springer, *Contemporary trends in tactical formations and team success in Europe's top-tier football leagues*: https://link.springer.com/article/10.1186/s13102-026-01657-1 diff --git a/e2e/formations.spec.ts b/e2e/formations.spec.ts index edfe61b..5ac29b0 100644 --- a/e2e/formations.spec.ts +++ b/e2e/formations.spec.ts @@ -73,11 +73,13 @@ test.describe("formations: board-first shape, keystone keycards, details, rondo await page.getByTestId("formations-details-close").click(); await expect(page.getByTestId("formations-details-panel")).toHaveCount(0); - // --- Rondo Map: toggle, five tappable zones, each shows its rondo and - // linked patterns (Brief step 18 DoD; seeds/rondo_zones.json, 433 only) --- + // --- Rondo Map: toggle, six tappable zones, each shows its rondo and + // linked patterns (Brief step 18 DoD; seeds/rondo_zones.json, 433 only; + // six not five since T-101/migration 0006 split flank_corridor into + // flank_corridor_left and flank_corridor_right) --- await page.getByTestId("formations-rondo-toggle").click(); await expect(page.getByTestId("formations-rondo-active-toggle")).toBeVisible(); - await expect(page.getByTestId("rondo-zone")).toHaveCount(5); + await expect(page.getByTestId("rondo-zone")).toHaveCount(6); await page.locator('[data-zone-key="midfield_box"]').click(); await expect(page.getByTestId("formations-zone-card")).toBeVisible(); diff --git a/frontend/src/board/geometry.ts b/frontend/src/board/geometry.ts index 41bad8e..e6e3cb9 100644 --- a/frontend/src/board/geometry.ts +++ b/frontend/src/board/geometry.ts @@ -67,6 +67,93 @@ export function closestPointOnSegment( return { point, distance: distance(p, point), t }; } +// --------------------------------------------------------------------------- +// Containment predicates (added by T-104 for the superiority engine, doc 06 +// section 4). They live here rather than in superiority.ts because this is the +// module the next person will look in for point-in-polygon, and one copy of that +// function is the whole point. superiority.ts re-exports them so the doc 06 API +// surface reads as written. Tests for both are in superiority.test.ts. +// +// BOUNDARY RULE: the boundary counts as INSIDE, for both the polygon and the +// circle. A player standing exactly on the edge of a rondo zone is in that zone. +// Two adjacent zones sharing an edge therefore both count a player standing on +// it, which is correct: zone counts are six independent readings of the same +// pitch, not a partition. The JdP grid in grid.ts IS a partition and uses a +// different, half-open rule; that difference is deliberate and documented there. +// --------------------------------------------------------------------------- + +/** Tolerance for "lies exactly on the boundary" in model units. Model space is + * 0-100, so 1e-9 is far below anything a coordinate can meaningfully express. + * No VERDICT depends on this: verdicts compare integer counts. */ +const ON_BOUNDARY_EPSILON = 1e-9; + +/** True when p lies on the closed segment [a,b], within the boundary tolerance. */ +function isOnSegment(p: ModelPoint, a: ModelPoint, b: ModelPoint): boolean { + // Reject anything outside the segment's bounding box first. This is what makes + // the colinear-but-beyond-the-end case false rather than true. + const minX = a.x < b.x ? a.x : b.x; + const maxX = a.x > b.x ? a.x : b.x; + const minY = a.y < b.y ? a.y : b.y; + const maxY = a.y > b.y ? a.y : b.y; + if ( + p.x < minX - ON_BOUNDARY_EPSILON || + p.x > maxX + ON_BOUNDARY_EPSILON || + p.y < minY - ON_BOUNDARY_EPSILON || + p.y > maxY + ON_BOUNDARY_EPSILON + ) { + return false; + } + // Colinearity: the cross product of (b-a) and (p-a) is zero on the line. + const cross = (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x); + return Math.abs(cross) <= ON_BOUNDARY_EPSILON; +} + +/** + * Ray casting containment, with the boundary counted as inside. + * + * Two steps, and the order matters: + * 1. If p lies on any edge or vertex, return true immediately. Ray casting + * gives an arbitrary answer for points exactly on the boundary, so the + * boundary is settled before the ray is ever cast. + * 2. Otherwise cast a ray in -x and count crossings, using the half-open + * comparison `(yi > p.y) !== (yj > p.y)`. That strict-on-one-side test is + * what fixes the classic bug where a ray passing exactly through a VERTEX + * counts that vertex twice (once for each edge meeting there) and flips the + * answer. With this form a vertex is counted by the edge below it and not by + * the edge above, so it contributes exactly one crossing. + * + * Winding direction does not matter. Concave polygons are handled correctly. + * A polygon with fewer than three vertices has no interior: only step 1 can + * return true, which makes a two point "polygon" behave as the segment it is. + */ +export function pointInPolygon(p: ModelPoint, poly: ModelPoint[]): boolean { + const n = poly.length; + if (n === 0) return false; + + for (let i = 0, j = n - 1; i < n; j = i, i += 1) { + if (isOnSegment(p, poly[j], poly[i])) return true; + } + if (n < 3) return false; + + let inside = false; + for (let i = 0, j = n - 1; i < n; j = i, i += 1) { + const yi = poly[i].y; + const yj = poly[j].y; + if (yi > p.y !== yj > p.y) { + // Parenthesised for the reader: (yi > p.y) !== (yj > p.y). + const xAtRay = ((poly[j].x - poly[i].x) * (p.y - yi)) / (yj - yi) + poly[i].x; + if (p.x < xAtRay) inside = !inside; + } + } + return inside; +} + +/** True when p is inside or exactly on the rim of the circle. Squared compare, + * so no square root and no rounding drift at the rim. */ +export function pointInCircle(p: ModelPoint, centre: ModelPoint, r: number): boolean { + return distanceSq(p, centre) <= r * r; +} + /** * Nearest item to `p` from a list, by squared distance. Returns null for an * empty list. Used to pick the marking defender and the ball holder. diff --git a/frontend/src/board/grid.test.ts b/frontend/src/board/grid.test.ts new file mode 100644 index 0000000..6db5917 --- /dev/null +++ b/frontend/src/board/grid.test.ts @@ -0,0 +1,203 @@ +// JdP grid tests (T-104), written BEFORE grid.ts exists. Doc 06 section 2.2 is +// the contract: five vertical lanes across y, five horizontal lines across x, +// and three occupancy guidelines that produce CHECKS, never errors. +// +// The boundary rule under test: every band is half-open and lower-inclusive, +// [min, max), except the final band which is closed, [min, max]. So y exactly 19 +// belongs to the left half-space (the higher band), not to the left wing, and +// y exactly 100 belongs to the right wing. Lanes and lines use the same rule. + +import { describe, expect, it } from "vitest"; +import { + JDP_GRID, + JDP_OCCUPANCY_LIMITS, + WIDE_LANE_KEYS, + cellAt, + cellKey, + gridOccupancy, + laneAt, + laneIndexAt, + lineAt, + lineIndexAt, +} from "./grid"; +import type { SlotPos } from "./superiorityTypes"; + +function at(slot: string, x: number, y: number): SlotPos { + return { slot, position_code: "XX", x, y }; +} + +describe("JDP_GRID is the single source of the doc 06 section 2.2 numbers", () => { + it("has the five lanes with the documented y ranges, in order", () => { + expect(JDP_GRID.lanes.map((l) => [l.key, l.min, l.max])).toEqual([ + ["left_wing", 0, 19], + ["left_half_space", 19, 37], + ["centre", 37, 63], + ["right_half_space", 63, 81], + ["right_wing", 81, 100], + ]); + }); + + it("has the five lines with the documented x ranges, in order", () => { + expect(JDP_GRID.lines.map((l) => [l.key, l.min, l.max])).toEqual([ + ["own_build", 0, 22], + ["first_line", 22, 42], + ["middle", 42, 60], + ["between_the_lines", 60, 78], + ["last_line", 78, 100], + ]); + }); + + it("tiles the pitch with no gap and no overlap", () => { + for (const bands of [JDP_GRID.lanes, JDP_GRID.lines]) { + expect(bands[0].min).toBe(0); + expect(bands[bands.length - 1].max).toBe(100); + for (let i = 1; i < bands.length; i += 1) { + expect(bands[i].min).toBe(bands[i - 1].max); + } + } + }); + + it("names exactly the two wide lanes and the three occupancy limits", () => { + expect(WIDE_LANE_KEYS).toEqual(["left_wing", "right_wing"]); + expect(JDP_OCCUPANCY_LIMITS).toEqual({ perLine: 3, perLane: 2, perWideLane: 1 }); + }); +}); + +describe("band lookup: lower-inclusive bands, closed at the far edge", () => { + it("places interior points in the obvious lane", () => { + expect(laneAt(0).key).toBe("left_wing"); + expect(laneAt(10).key).toBe("left_wing"); + expect(laneAt(28).key).toBe("left_half_space"); + expect(laneAt(50).key).toBe("centre"); + expect(laneAt(72).key).toBe("right_half_space"); + expect(laneAt(95).key).toBe("right_wing"); + }); + + it("puts a point exactly on a lane boundary in the HIGHER band", () => { + expect(laneAt(19).key).toBe("left_half_space"); + expect(laneAt(37).key).toBe("centre"); + expect(laneAt(63).key).toBe("right_half_space"); + expect(laneAt(81).key).toBe("right_wing"); + }); + + it("puts a point exactly on a line boundary in the HIGHER band, same rule", () => { + expect(lineAt(22).key).toBe("first_line"); + expect(lineAt(42).key).toBe("middle"); + expect(lineAt(60).key).toBe("between_the_lines"); + expect(lineAt(78).key).toBe("last_line"); + }); + + it("closes the final band so 100 is inside the pitch, not past it", () => { + expect(laneAt(100).key).toBe("right_wing"); + expect(lineAt(100).key).toBe("last_line"); + }); + + it("clamps out-of-pitch coordinates into the end bands rather than throwing", () => { + expect(laneIndexAt(-5)).toBe(0); + expect(laneIndexAt(140)).toBe(4); + expect(lineIndexAt(-0.001)).toBe(0); + expect(lineIndexAt(100.001)).toBe(4); + }); + + it("assigns every integer coordinate to exactly one band", () => { + for (let v = 0; v <= 100; v += 1) { + const lane = laneIndexAt(v); + const line = lineIndexAt(v); + expect(lane).toBeGreaterThanOrEqual(0); + expect(lane).toBeLessThan(JDP_GRID.lanes.length); + expect(line).toBeGreaterThanOrEqual(0); + expect(line).toBeLessThan(JDP_GRID.lines.length); + } + }); +}); + +describe("cellAt / cellKey", () => { + it("combines the lane and the line into one stable key", () => { + const cell = cellAt({ x: 65, y: 28 }); + expect(cell.lane).toBe("left_half_space"); + expect(cell.line).toBe("between_the_lines"); + expect(cell.key).toBe(cellKey("left_half_space", "between_the_lines")); + }); + + it("keys the corner cell by the same boundary rule", () => { + expect(cellAt({ x: 78, y: 81 }).key).toBe(cellKey("right_wing", "last_line")); + }); +}); + +describe("gridOccupancy: guidelines produce checks, never errors", () => { + it("reports no breaches for a well spread shape", () => { + const ours = [ + at("LW", 80, 10), + at("LCM", 55, 28), + at("ST", 85, 50), + at("RCM", 55, 72), + at("RW", 80, 90), + ]; + const { breaches } = gridOccupancy(ours); + expect(breaches).toEqual([]); + }); + + it("lists occupants per occupied cell only, in input order", () => { + const { occupancy } = gridOccupancy([at("A", 65, 28), at("B", 66, 30), at("C", 10, 50)]); + expect(occupancy).toEqual({ + [cellKey("left_half_space", "between_the_lines")]: ["A", "B"], + [cellKey("centre", "own_build")]: ["C"], + }); + }); + + it("flags more than two teammates in a vertical lane", () => { + const ours = [at("A", 30, 28), at("B", 55, 30), at("C", 80, 25)]; + const { breaches } = gridOccupancy(ours); + expect(breaches).toEqual([ + { kind: "lane_over", cell: "left_half_space", count: 3, slots: ["A", "B", "C"] }, + ]); + }); + + it("flags more than three teammates on a horizontal line", () => { + const ours = [at("A", 50, 10), at("B", 50, 30), at("C", 50, 50), at("D", 50, 70)]; + const { breaches } = gridOccupancy(ours); + expect(breaches).toEqual([ + { kind: "line_over", cell: "middle", count: 4, slots: ["A", "B", "C", "D"] }, + ]); + }); + + it("flags a shared wide lane at two occupants, before the lane limit bites", () => { + const ours = [at("LB", 40, 8), at("LW", 80, 12)]; + const { breaches } = gridOccupancy(ours); + expect(breaches).toEqual([ + { kind: "wide_lane_shared", cell: "left_wing", count: 2, slots: ["LB", "LW"] }, + ]); + }); + + it("raises both wide-lane checks when a wide lane holds three, since they are two different guidelines", () => { + const ours = [at("LB", 30, 8), at("LM", 55, 12), at("LW", 85, 5)]; + const { breaches } = gridOccupancy(ours); + expect(breaches.map((b) => b.kind)).toEqual(["lane_over", "wide_lane_shared"]); + }); + + it("orders breaches deterministically: lanes by index, then lines by index", () => { + const ours = [ + at("A", 50, 8), + at("B", 50, 12), + at("C", 50, 50), + at("D", 50, 55), + at("E", 50, 90), + at("F", 50, 95), + ]; + const { breaches } = gridOccupancy(ours); + expect(breaches.map((b) => [b.kind, b.cell])).toEqual([ + ["wide_lane_shared", "left_wing"], + ["wide_lane_shared", "right_wing"], + ["line_over", "middle"], + ]); + }); + + it("is empty for an empty shape", () => { + expect(gridOccupancy([])).toEqual({ occupancy: {}, breaches: [] }); + }); + + it("is deterministic: the same input gives a deeply equal result every time", () => { + const ours = [at("A", 30, 28), at("B", 55, 30), at("C", 80, 25), at("D", 50, 90)]; + expect(gridOccupancy(ours)).toEqual(gridOccupancy(ours)); + }); +}); diff --git a/frontend/src/board/grid.ts b/frontend/src/board/grid.ts new file mode 100644 index 0000000..34c5ee2 --- /dev/null +++ b/frontend/src/board/grid.ts @@ -0,0 +1,178 @@ +// The juego de posicion grid (T-104, doc 06 section 2.2). Pure, deterministic, +// framework free. Landscape model coords only (CLAUDE.md rule 8): lanes divide y, +// horizontal lines divide x. Orientation never appears here. +// +// The occupancy rules produce CHECKS, never errors. Doc 06 is explicit that a +// temporary breach is legal football (forming a triangle, creating an overload, +// dragging a marker out) and that the copy is "check this", not "wrong". So this +// module reports breaches and lets the surface phrase them; it never blocks. + +import type { + GridBand, + GridBreach, + GridCell, + LaneKey, + LineKey, + Pt, + SlotPos, +} from "./superiorityTypes"; + +// --------------------------------------------------------------------------- +// THE CONTRACT. Doc 06 section 2.2: "These numbers are the contract. Any ticket +// changing them changes the seeds too, and both move together in one PR." +// This constant is the ONLY place the numbers live. Change them here and the +// whole engine, every breach check and every free man follows. +// --------------------------------------------------------------------------- +export const JDP_GRID: { + lanes: GridBand[]; + lines: GridBand[]; +} = { + // Vertical lanes, across y (0 at the top of the pitch). + lanes: [ + { key: "left_wing", label: "Left wing", min: 0, max: 19 }, + { key: "left_half_space", label: "Left half-space", min: 19, max: 37 }, + { key: "centre", label: "Centre", min: 37, max: 63 }, + { key: "right_half_space", label: "Right half-space", min: 63, max: 81 }, + { key: "right_wing", label: "Right wing", min: 81, max: 100 }, + ], + // Horizontal lines, across x (0 at our own goal, 100 at the attacking goal). + lines: [ + { key: "own_build", label: "Own build", min: 0, max: 22 }, + { key: "first_line", label: "First line", min: 22, max: 42 }, + { key: "middle", label: "Middle", min: 42, max: 60 }, + { key: "between_the_lines", label: "Between the lines", min: 60, max: 78 }, + { key: "last_line", label: "Last line", min: 78, max: 100 }, + ], +}; + +/** The two wide lanes, which carry the tighter one-occupant guideline. */ +export const WIDE_LANE_KEYS: LaneKey[] = ["left_wing", "right_wing"]; + +/** Doc 06 section 2.2 occupancy guidelines. Counts above these raise a check. */ +export const JDP_OCCUPANCY_LIMITS = { + /** No more than three teammates on any horizontal line. */ + perLine: 3, + /** No more than two teammates in any vertical lane. */ + perLane: 2, + /** Wide lanes: one occupant each, whenever possible. */ + perWideLane: 1, +} as const; + +// --------------------------------------------------------------------------- +// BOUNDARY RULE, stated once and applied to lanes and lines identically. +// +// Every band is half-open and LOWER inclusive: [min, max). The final band is +// closed, [min, max], so a coordinate of exactly 100 is on the pitch rather than +// off the end of it. Consequences worth knowing: +// - y exactly 19 is in the LEFT HALF-SPACE, not the left wing. +// - x exactly 78 is on the LAST LINE, not between the lines. +// - a coordinate on a boundary therefore belongs to exactly one band, always +// the higher one, so occupancy counts can never double count a player. +// Coordinates outside 0-100 clamp into the end bands rather than throwing, so a +// token dragged a hair off the touchline still classifies instead of crashing a +// drag frame. +// +// Note this is a DIFFERENT question from pointInPolygon's boundary rule, where a +// point on a zone edge counts as inside BOTH neighbouring zones. That is correct +// there: zone counts are independent readings of the same pitch and are allowed +// to overlap. Grid cells are a partition and must not. +// --------------------------------------------------------------------------- + +function indexIn(bands: GridBand[], v: number): number { + for (let i = 0; i < bands.length - 1; i += 1) { + if (v < bands[i].max) return i; + } + return bands.length - 1; +} + +/** Index of the vertical lane containing model y. Clamped, never out of range. */ +export function laneIndexAt(y: number): number { + return y < 0 ? 0 : indexIn(JDP_GRID.lanes, y); +} + +/** Index of the horizontal line containing model x. Clamped, never out of range. */ +export function lineIndexAt(x: number): number { + return x < 0 ? 0 : indexIn(JDP_GRID.lines, x); +} + +export function laneAt(y: number): GridBand { + return JDP_GRID.lanes[laneIndexAt(y)]; +} + +export function lineAt(x: number): GridBand { + return JDP_GRID.lines[lineIndexAt(x)]; +} + +/** Stable composite key for a grid cell. */ +export function cellKey(lane: LaneKey, line: LineKey): string { + return `${lane}/${line}`; +} + +/** The grid cell a model point falls in. */ +export function cellAt(p: Pt): GridCell { + const lane = laneAt(p.y).key; + const line = lineAt(p.x).key; + return { lane, line, key: cellKey(lane, line) }; +} + +/** + * Occupancy of the grid by our shape, plus every guideline breach. + * + * `occupancy` holds only the OCCUPIED cells, keyed by cellKey, each listing its + * slots in the caller's input order. Empty cells are omitted: twenty five keys + * of mostly empty arrays is noise to iterate on every drag frame. + * + * `breaches` are ordered deterministically: lanes in grid order first (lane_over + * before wide_lane_shared within a lane), then lines in grid order. A wide lane + * holding three raises BOTH wide-lane checks, because doc 06 lists them as two + * separate guidelines: "no more than two in any vertical lane" and "wide lanes, + * one occupant each". They fail for different reasons and a coach reading the + * board deserves both sentences. + */ +export function gridOccupancy(ours: SlotPos[]): { + occupancy: Record; + breaches: GridBreach[]; +} { + const occupancy: Record = {}; + const byLane: string[][] = JDP_GRID.lanes.map(() => []); + const byLine: string[][] = JDP_GRID.lines.map(() => []); + + for (const p of ours) { + const laneIdx = laneIndexAt(p.y); + const lineIdx = lineIndexAt(p.x); + byLane[laneIdx].push(p.slot); + byLine[lineIdx].push(p.slot); + const key = cellKey(JDP_GRID.lanes[laneIdx].key, JDP_GRID.lines[lineIdx].key); + if (occupancy[key]) occupancy[key].push(p.slot); + else occupancy[key] = [p.slot]; + } + + const breaches: GridBreach[] = []; + + JDP_GRID.lanes.forEach((lane, i) => { + const slots = byLane[i]; + if (slots.length > JDP_OCCUPANCY_LIMITS.perLane) { + breaches.push({ kind: "lane_over", cell: lane.key, count: slots.length, slots: [...slots] }); + } + if ( + WIDE_LANE_KEYS.includes(lane.key) && + slots.length > JDP_OCCUPANCY_LIMITS.perWideLane + ) { + breaches.push({ + kind: "wide_lane_shared", + cell: lane.key, + count: slots.length, + slots: [...slots], + }); + } + }); + + JDP_GRID.lines.forEach((line, i) => { + const slots = byLine[i]; + if (slots.length > JDP_OCCUPANCY_LIMITS.perLine) { + breaches.push({ kind: "line_over", cell: line.key, count: slots.length, slots: [...slots] }); + } + }); + + return { occupancy, breaches }; +} diff --git a/frontend/src/board/superiority.bench.test.ts b/frontend/src/board/superiority.bench.test.ts new file mode 100644 index 0000000..5ff172f --- /dev/null +++ b/frontend/src/board/superiority.bench.test.ts @@ -0,0 +1,153 @@ +// Recompute benchmark (T-104, doc 06 section 4 "Performance"). The engine runs on +// every drag frame, so a FULL recompute with 22 tokens and 6 zones must stay under +// 2ms. Doc 06 says this must be measured, not assumed. +// +// Method, so a reviewer can judge whether the threshold is honest: +// - The workload is one complete recompute: mirror all 11 opponents, run +// findFreeMen, count all 6 zones, run gridOccupancy, classifyRestDefence and +// buildRead. Nothing is hoisted out of the timed region except the fixture. +// - WARMUP_RUNS untimed iterations first, so the JIT has tiered up and the +// shapes are monomorphic before anything is recorded. +// - Then SAMPLE_RUNS individually timed iterations. We assert on the MEDIAN, +// not the mean and not the max, because CI shares a machine and a single +// preemption or a GC pause would otherwise flake the build. +// - A separate, much looser ceiling on the 99th percentile catches a genuine +// pathological case (an accidental O(n^3)) without flaking on scheduler noise. +// +// The result is printed so the number is visible in the verify log rather than +// only living in an assertion. + +import { describe, expect, it } from "vitest"; +import { + SUPERIORITY_ZONE_KEYS, + buildRead, + classifyRestDefence, + countZone, + findFreeMen, + mirrorOpponent, +} from "./superiority"; +import { gridOccupancy } from "./grid"; +import type { SlotPos, SuperiorityZone } from "./superiorityTypes"; + +const WARMUP_RUNS = 300; +const SAMPLE_RUNS = 501; +const MEDIAN_BUDGET_MS = 2; +const P99_BUDGET_MS = 12; + +function at(slot: string, x: number, y: number): SlotPos { + return { slot, position_code: slot, x, y }; +} + +/** Our shape: a 3-2-5 in possession, 11 slots in landscape model coords. */ +const OURS: SlotPos[] = [ + at("GK", 6, 50), + at("LCB", 24, 30), + at("CB", 22, 50), + at("RCB", 24, 70), + at("LB", 44, 44), + at("DM", 46, 56), + at("LCM", 66, 28), + at("RCM", 66, 72), + at("LW", 82, 8), + at("ST", 88, 50), + at("RW", 82, 92), +]; + +/** Their shape: a 4-4-2 mid block, authored in THEIR frame then mirrored into ours. */ +const THEIRS_OWN_FRAME: SlotPos[] = [ + at("oGK", 5, 50), + at("oLB", 20, 18), + at("oLCB", 18, 40), + at("oRCB", 18, 60), + at("oRB", 20, 82), + at("oLM", 40, 18), + at("oLCM", 38, 40), + at("oRCM", 38, 60), + at("oRM", 40, 82), + at("oST1", 60, 42), + at("oST2", 60, 58), +]; + +function polygon(zoneKey: string, x0: number, y0: number, x1: number, y1: number): SuperiorityZone { + return { + zoneKey, + kind: "polygon", + polygon: [ + { x: x0, y: y0 }, + { x: x1, y: y0 }, + { x: x1, y: y1 }, + { x: x0, y: y1 }, + ], + }; +} + +const K = SUPERIORITY_ZONE_KEYS; + +/** The six zones of doc 06 section 2.3: five polygons plus the ball-relative ring. */ +const ZONES: SuperiorityZone[] = [ + polygon(K.firstLine, 0, 20, 40, 80), + polygon(K.midfieldBox, 40, 30, 70, 70), + polygon(K.flankCorridorLeft, 0, 0, 100, 30), + polygon(K.flankCorridorRight, 0, 70, 100, 100), + polygon(K.lastLine, 70, 15, 100, 85), + { zoneKey: K.counterpressRing, kind: "circle", centre: { x: 70, y: 50 }, radius: 18 }, +]; + +const BALL_X = 70; + +/** One complete recompute, exactly as a drag frame would run it. */ +function recompute(): number { + const theirs: SlotPos[] = THEIRS_OWN_FRAME.map((t) => { + const m = mirrorOpponent({ x: t.x, y: t.y }); + return { slot: t.slot, position_code: t.position_code, x: m.x, y: m.y }; + }); + + const freeMen = findFreeMen(OURS, theirs); + const freeSlots = new Set(freeMen.map((f) => f.slot)); + const counts = ZONES.map((z) => countZone(z, OURS, theirs, freeSlots)); + const grid = gridOccupancy(OURS); + const rest = classifyRestDefence(OURS, BALL_X); + const read = buildRead(counts, null); + + // Touch the results so nothing can be optimised away as dead code. + return counts.length + grid.breaches.length + rest.behindBall + (read.spare ? 1 : 0) + freeMen.length; +} + +function percentile(sorted: number[], p: number): number { + const idx = Math.min(sorted.length - 1, Math.max(0, Math.round((sorted.length - 1) * p))); + return sorted[idx]; +} + +describe("superiority engine recompute benchmark: 22 tokens, 6 zones", () => { + it("exercises the whole engine in one pass", () => { + // Guard the guard: if the workload ever stops doing real work, the timing + // below becomes meaningless, so assert the fixture actually produces a read. + expect(OURS).toHaveLength(11); + expect(THEIRS_OWN_FRAME).toHaveLength(11); + expect(ZONES).toHaveLength(6); + expect(recompute()).toBeGreaterThan(0); + }); + + it(`stays under ${MEDIAN_BUDGET_MS}ms per recompute at the median`, () => { + for (let i = 0; i < WARMUP_RUNS; i += 1) recompute(); + + const samples: number[] = new Array(SAMPLE_RUNS); + for (let i = 0; i < SAMPLE_RUNS; i += 1) { + const t0 = performance.now(); + recompute(); + samples[i] = performance.now() - t0; + } + samples.sort((a, b) => a - b); + + const median = percentile(samples, 0.5); + const p99 = percentile(samples, 0.99); + // eslint-disable-next-line no-console + console.log( + `[T-104 benchmark] recompute n=${SAMPLE_RUNS} median=${median.toFixed(4)}ms ` + + `p99=${p99.toFixed(4)}ms max=${samples[samples.length - 1].toFixed(4)}ms` + ); + + expect(median).toBeLessThan(MEDIAN_BUDGET_MS); + expect(p99).toBeLessThan(P99_BUDGET_MS); + }); +}); diff --git a/frontend/src/board/superiority.test.ts b/frontend/src/board/superiority.test.ts new file mode 100644 index 0000000..80d0988 --- /dev/null +++ b/frontend/src/board/superiority.test.ts @@ -0,0 +1,640 @@ +// Superiority engine tests (T-104), written BEFORE superiority.ts exists. Doc 06 +// section 4 is the contract. The mirror round-trip property test is first in the +// file because it was first in the working order: no mirror code existed when it +// was written, exactly as the T-020 coords round-trip precedent demands. +// +// Everything here is in LANDSCAPE MODEL coordinates (CLAUDE.md rule 8). No test +// in this file mentions orientation, because the engine never sees it. + +import { describe, expect, it } from "vitest"; +import type { ModelPoint } from "./coords"; +import { cellKey } from "./grid"; +import { + DEFAULT_PRESS_RADIUS, + SUPERIORITY_ZONE_KEYS, + buildRead, + classifyRestDefence, + countZone, + findFreeMen, + mirrorOpponent, + pointInCircle, + pointInPolygon, +} from "./superiority"; +import type { FormationMatchup, SlotPos, SuperiorityZone } from "./superiorityTypes"; + +function at(slot: string, x: number, y: number): SlotPos { + return { slot, position_code: "XX", x, y }; +} + +/** An axis-aligned rectangular zone, the common case, as a polygon. */ +function rect(zoneKey: string, x0: number, y0: number, x1: number, y1: number): SuperiorityZone { + return { + zoneKey, + kind: "polygon", + polygon: [ + { x: x0, y: y0 }, + { x: x1, y: y0 }, + { x: x1, y: y1 }, + { x: x0, y: y1 }, + ], + }; +} + +// --------------------------------------------------------------------------- +// 1. mirrorOpponent. The involutive property, tested across the whole grid. +// --------------------------------------------------------------------------- + +describe("mirrorOpponent", () => { + it("rotates 180 degrees about the pitch centre", () => { + expect(mirrorOpponent({ x: 0, y: 0 })).toEqual({ x: 100, y: 100 }); + expect(mirrorOpponent({ x: 50, y: 50 })).toEqual({ x: 50, y: 50 }); + expect(mirrorOpponent({ x: 80, y: 19 })).toEqual({ x: 20, y: 81 }); + }); + + it("is EXACTLY involutive across the full integer pitch grid (10201 points)", () => { + let checked = 0; + for (let x = 0; x <= 100; x += 1) { + for (let y = 0; y <= 100; y += 1) { + const p: ModelPoint = { x, y }; + const back = mirrorOpponent(mirrorOpponent(p)); + // Object.is, not toBeCloseTo: the contract is exactness, and -0 would be a bug. + if (!Object.is(back.x, p.x) || !Object.is(back.y, p.y)) { + throw new Error(`mirror not involutive at (${x},${y}), got (${back.x},${back.y})`); + } + checked += 1; + } + } + expect(checked).toBe(101 * 101); + }); + + it("stays involutive for integers outside the pitch, so it never silently clamps", () => { + for (let x = -50; x <= 150; x += 7) { + for (let y = -50; y <= 150; y += 11) { + expect(mirrorOpponent(mirrorOpponent({ x, y }))).toEqual({ x, y }); + } + } + }); + + it("maps the half-space band onto the opposite half-space band", () => { + // Left half-space is y 19 to 37; mirrored it must land in the right half-space, 63 to 81. + expect(mirrorOpponent({ x: 30, y: 19 }).y).toBe(81); + expect(mirrorOpponent({ x: 30, y: 37 }).y).toBe(63); + }); + + it("does not mutate its input", () => { + const p = { x: 12, y: 34 }; + mirrorOpponent(p); + expect(p).toEqual({ x: 12, y: 34 }); + }); +}); + +// --------------------------------------------------------------------------- +// 2. Geometry. Boundary counts as INSIDE, and the ray must survive a vertex. +// --------------------------------------------------------------------------- + +describe("pointInPolygon: boundary counts as inside", () => { + const square = [ + { x: 0, y: 0 }, + { x: 10, y: 0 }, + { x: 10, y: 10 }, + { x: 0, y: 10 }, + ]; + + it("accepts interior points and rejects exterior points", () => { + expect(pointInPolygon({ x: 5, y: 5 }, square)).toBe(true); + expect(pointInPolygon({ x: 15, y: 5 }, square)).toBe(false); + expect(pointInPolygon({ x: -1, y: 5 }, square)).toBe(false); + expect(pointInPolygon({ x: 5, y: 11 }, square)).toBe(false); + }); + + it("accepts every vertex", () => { + for (const v of square) expect(pointInPolygon(v, square)).toBe(true); + }); + + it("accepts points lying on every edge", () => { + expect(pointInPolygon({ x: 5, y: 0 }, square)).toBe(true); + expect(pointInPolygon({ x: 10, y: 5 }, square)).toBe(true); + expect(pointInPolygon({ x: 5, y: 10 }, square)).toBe(true); + expect(pointInPolygon({ x: 0, y: 5 }, square)).toBe(true); + }); + + it("does not leak along the extension of a horizontal edge", () => { + expect(pointInPolygon({ x: 20, y: 0 }, square)).toBe(false); + expect(pointInPolygon({ x: -20, y: 10 }, square)).toBe(false); + }); + + describe("the classic bug: the ray passes exactly through a vertex", () => { + // A diamond whose left and right vertices both sit at y = 5, so a horizontal + // ray at y = 5 hits two vertices dead on. Naive ray casting double counts. + const diamond = [ + { x: 5, y: 0 }, + { x: 10, y: 5 }, + { x: 5, y: 10 }, + { x: 0, y: 5 }, + ]; + + it("still calls the outside point outside", () => { + expect(pointInPolygon({ x: 12, y: 5 }, diamond)).toBe(false); + expect(pointInPolygon({ x: -2, y: 5 }, diamond)).toBe(false); + }); + + it("still calls the inside point inside", () => { + expect(pointInPolygon({ x: 5, y: 5 }, diamond)).toBe(true); + }); + + it("calls the two grazed vertices inside, because the boundary is inside", () => { + expect(pointInPolygon({ x: 10, y: 5 }, diamond)).toBe(true); + expect(pointInPolygon({ x: 0, y: 5 }, diamond)).toBe(true); + }); + + it("handles a ray grazing the apex of a triangle", () => { + const tri = [ + { x: 0, y: 0 }, + { x: 10, y: 0 }, + { x: 5, y: 10 }, + ]; + expect(pointInPolygon({ x: 20, y: 10 }, tri)).toBe(false); + expect(pointInPolygon({ x: 5, y: 10 }, tri)).toBe(true); // the apex itself + expect(pointInPolygon({ x: 5, y: 1 }, tri)).toBe(true); + }); + }); + + it("respects a concave notch", () => { + // An L shape. The notch at (8,8) is outside even though it is inside the bbox. + const ell = [ + { x: 0, y: 0 }, + { x: 10, y: 0 }, + { x: 10, y: 4 }, + { x: 4, y: 4 }, + { x: 4, y: 10 }, + { x: 0, y: 10 }, + ]; + expect(pointInPolygon({ x: 2, y: 2 }, ell)).toBe(true); + expect(pointInPolygon({ x: 8, y: 2 }, ell)).toBe(true); + expect(pointInPolygon({ x: 2, y: 8 }, ell)).toBe(true); + expect(pointInPolygon({ x: 8, y: 8 }, ell)).toBe(false); + expect(pointInPolygon({ x: 4, y: 4 }, ell)).toBe(true); // reflex vertex + }); + + it("gives the same answer whichever way the polygon is wound", () => { + const reversed = [...square].reverse(); + for (const p of [{ x: 5, y: 5 }, { x: 0, y: 0 }, { x: 15, y: 5 }, { x: 5, y: 0 }]) { + expect(pointInPolygon(p, reversed)).toBe(pointInPolygon(p, square)); + } + }); + + it("returns false for degenerate polygons rather than throwing", () => { + expect(pointInPolygon({ x: 1, y: 1 }, [])).toBe(false); + expect(pointInPolygon({ x: 1, y: 1 }, [{ x: 0, y: 0 }])).toBe(false); + // A two point "polygon" is a segment: only the segment itself counts. + expect(pointInPolygon({ x: 1, y: 0 }, [{ x: 0, y: 0 }, { x: 2, y: 0 }])).toBe(true); + expect(pointInPolygon({ x: 1, y: 1 }, [{ x: 0, y: 0 }, { x: 2, y: 0 }])).toBe(false); + }); +}); + +describe("pointInCircle: boundary counts as inside, matching the polygon rule", () => { + const centre = { x: 50, y: 50 }; + it("accepts the centre and interior", () => { + expect(pointInCircle(centre, centre, 18)).toBe(true); + expect(pointInCircle({ x: 60, y: 55 }, centre, 18)).toBe(true); + }); + it("accepts a point exactly on the rim", () => { + expect(pointInCircle({ x: 68, y: 50 }, centre, 18)).toBe(true); + expect(pointInCircle({ x: 50, y: 32 }, centre, 18)).toBe(true); + expect(pointInCircle({ x: 53, y: 54 }, centre, 5)).toBe(true); // 3-4-5 triangle + }); + it("rejects a point just outside the rim", () => { + expect(pointInCircle({ x: 68.5, y: 50 }, centre, 18)).toBe(false); + }); + it("treats a zero radius as the single centre point", () => { + expect(pointInCircle(centre, centre, 0)).toBe(true); + expect(pointInCircle({ x: 50.1, y: 50 }, centre, 0)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// 3. countZone. +// --------------------------------------------------------------------------- + +describe("countZone", () => { + const midfieldBox = rect(SUPERIORITY_ZONE_KEYS.midfieldBox, 40, 30, 70, 70); + + it("counts both sides, labels the ratio, and reports the delta", () => { + const ours = [at("6", 45, 50), at("8L", 55, 40), at("8R", 55, 60), at("10", 65, 50)]; + const theirs = [at("d6", 60, 45), at("d8", 62, 55)]; + const c = countZone(midfieldBox, ours, theirs); + expect(c.zoneKey).toBe("midfield_box"); + expect(c.ours).toBe(4); + expect(c.theirs).toBe(2); + expect(c.delta).toBe(2); + expect(c.label).toBe("4v2"); + expect(c.verdict).toBe("superiority"); + expect(c.superiorityKind).toBe("numerical"); + }); + + it("calls equal counts parity with no superiority kind", () => { + const c = countZone(midfieldBox, [at("6", 45, 50)], [at("d6", 60, 45)]); + expect(c.delta).toBe(0); + expect(c.label).toBe("1v1"); + expect(c.verdict).toBe("parity"); + expect(c.superiorityKind).toBeNull(); + }); + + it("calls being outnumbered inferiority", () => { + const c = countZone(midfieldBox, [at("6", 45, 50)], [at("a", 60, 45), at("b", 62, 55)]); + expect(c.delta).toBe(-1); + expect(c.label).toBe("1v2"); + expect(c.verdict).toBe("inferiority"); + expect(c.superiorityKind).toBeNull(); + }); + + it("upgrades parity to POSITIONAL superiority when one of ours in the zone is a free man", () => { + const ours = [at("10", 65, 50)]; + const theirs = [at("d6", 45, 45)]; + const c = countZone(midfieldBox, ours, theirs, new Set(["10"])); + expect(c.verdict).toBe("parity"); + expect(c.superiorityKind).toBe("positional"); + }); + + it("ignores a free man who is standing outside the zone", () => { + const c = countZone(midfieldBox, [at("10", 65, 50)], [at("d6", 45, 45)], new Set(["LW"])); + expect(c.superiorityKind).toBeNull(); + }); + + it("keeps numerical when we are already up bodies, even with a free man there", () => { + const ours = [at("8", 55, 40), at("10", 65, 50)]; + const c = countZone(midfieldBox, ours, [at("d6", 45, 45)], new Set(["10"])); + expect(c.superiorityKind).toBe("numerical"); + }); + + it("counts a player standing exactly on the zone edge as inside", () => { + const c = countZone(midfieldBox, [at("6", 40, 50)], []); + expect(c.ours).toBe(1); + expect(c.label).toBe("1v0"); + }); + + it("counts inside a ball-relative circle zone", () => { + const ring: SuperiorityZone = { + zoneKey: SUPERIORITY_ZONE_KEYS.counterpressRing, + kind: "circle", + centre: { x: 70, y: 50 }, + radius: 18, + }; + const ours = [at("8", 60, 50), at("10", 70, 60), at("LW", 20, 20)]; + const theirs = [at("d6", 88, 50)]; // exactly on the rim, so inside + const c = countZone(ring, ours, theirs); + expect(c.ours).toBe(2); + expect(c.theirs).toBe(1); + expect(c.label).toBe("2v1"); + }); + + it("exposes an anchorX so buildRead can break ties toward our own goal", () => { + expect(countZone(midfieldBox, [], []).anchorX).toBe(55); + const ring: SuperiorityZone = { + zoneKey: "counterpress_ring", + kind: "circle", + centre: { x: 70, y: 50 }, + radius: 18, + }; + expect(countZone(ring, [], []).anchorX).toBe(70); + }); + + it("handles an empty pitch without dividing by anything", () => { + const c = countZone(midfieldBox, [], []); + expect(c.label).toBe("0v0"); + expect(c.verdict).toBe("parity"); + }); +}); + +// --------------------------------------------------------------------------- +// 4. findFreeMen. +// --------------------------------------------------------------------------- + +describe("findFreeMen", () => { + it("defaults the press radius to 8 model units", () => { + expect(DEFAULT_PRESS_RADIUS).toBe(8); + }); + + it("finds one of ours alone in a cell, unpressed, between two of their lines", () => { + const ours = [at("10", 65, 28)]; + const theirs = [at("d6", 50, 28), at("cb", 85, 28)]; + const free = findFreeMen(ours, theirs); + expect(free).toHaveLength(1); + expect(free[0].slot).toBe("10"); + expect(free[0].cell.key).toBe(cellKey("left_half_space", "between_the_lines")); + }); + + it("names the superiority it is talking about in coach-facing copy", () => { + const free = findFreeMen([at("10", 65, 28)], [at("d6", 50, 28), at("cb", 85, 28)]); + const why = free[0].whyItMatters; + expect(why).toContain("Positional superiority"); + expect(why).toContain("left half-space"); + // Built from the code point, never typed literally: check_copy.py scans this + // file too, and an assertion that spells out the banned character fails CI. + expect(why).not.toContain(String.fromCharCode(0x2014)); // em dash + expect(why).not.toContain(String.fromCharCode(0x2013)); // en dash + expect(why.length).toBeGreaterThan(40); + }); + + it("does not call a pressed player free", () => { + // The presser sits in the neighbouring cell, so only the press rule can bite. + const ours = [at("10", 65, 28)]; + const theirs = [at("d6", 50, 28), at("cb", 85, 28), at("presser", 59, 28)]; + expect(findFreeMen(ours, theirs)).toEqual([]); + }); + + it("treats a defender exactly at the press radius as pressing", () => { + const ours = [at("10", 65, 28)]; + const base = [at("d6", 50, 28), at("cb", 85, 28)]; + // x 57 puts the presser in the next line band, so the cell rule stays out of it. + expect(findFreeMen(ours, [...base, at("p", 57, 28)])).toEqual([]); // distance exactly 8 + expect(findFreeMen(ours, [...base, at("p", 56.9, 28)])).toHaveLength(1); + }); + + it("honours a custom press radius", () => { + const ours = [at("10", 65, 28)]; + const theirs = [at("d6", 50, 28), at("cb", 85, 28), at("p", 65, 40)]; // 12 away + expect(findFreeMen(ours, theirs)).toHaveLength(1); + expect(findFreeMen(ours, theirs, 14)).toEqual([]); + }); + + it("does not call a player free when a teammate shares the cell", () => { + const ours = [at("10", 65, 28), at("8", 66, 30)]; + const theirs = [at("d6", 50, 28), at("cb", 85, 28)]; + expect(findFreeMen(ours, theirs)).toEqual([]); + }); + + it("does not call a player free when an opponent shares the cell", () => { + const ours = [at("10", 65, 28)]; + const theirs = [at("d6", 50, 28), at("cb", 85, 28), at("marker", 76, 20)]; + // The marker shares the cell but sits more than 8 away, so only the cell rule bites. + expect(findFreeMen(ours, theirs)).toEqual([]); + }); + + it("requires opponents both in front and behind: being between their LINES is the point", () => { + // Nobody behind this striker, so he has run beyond their last line rather than + // found a pocket between two of them. Offside, not free. + const ours = [at("ST", 90, 50)]; + const theirs = [at("cb1", 95, 20), at("cb2", 96, 80)]; + expect(findFreeMen(ours, theirs)).toEqual([]); + // Add a deeper opponent line and the same player is now genuinely between lines. + expect(findFreeMen(ours, [at("cb1", 95, 20), at("d6", 55, 50)])).toHaveLength(1); + }); + + it("returns free men in the input order of ours, deterministically", () => { + const ours = [at("A", 65, 28), at("B", 65, 72)]; + const theirs = [at("d1", 50, 50), at("d2", 90, 50)]; + expect(findFreeMen(ours, theirs).map((f) => f.slot)).toEqual(["A", "B"]); + expect(findFreeMen(ours, theirs)).toEqual(findFreeMen(ours, theirs)); + }); + + it("finds nobody when there are no opponents at all", () => { + expect(findFreeMen([at("10", 65, 28)], [])).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// 5. classifyRestDefence. +// --------------------------------------------------------------------------- + +describe("classifyRestDefence", () => { + it("splits the players behind the ball into a last line and a screen at a 12 unit gap", () => { + const ours = [ + at("CB1", 20, 40), + at("CB2", 20, 60), + at("CB3", 22, 50), + at("6", 40, 45), + at("8", 42, 55), + at("ST", 85, 50), // ahead of the ball, not counted + ]; + const r = classifyRestDefence(ours, 60); + expect(r.behindBall).toBe(5); + expect(r.lastLine).toBe(3); + expect(r.screen).toBe(2); + expect(r.shape).toBe("3+2"); + }); + + it("reads a 4+2", () => { + const ours = [ + at("LB", 18, 15), + at("CB1", 20, 40), + at("CB2", 20, 60), + at("RB", 18, 85), + at("6", 38, 45), + at("8", 40, 55), + ]; + expect(classifyRestDefence(ours, 70).shape).toBe("4+2"); + }); + + it("reads a 2+3", () => { + const ours = [ + at("CB1", 15, 40), + at("CB2", 15, 60), + at("LB", 45, 20), + at("6", 44, 50), + at("RB", 45, 80), + ]; + const r = classifyRestDefence(ours, 75); + expect(r.lastLine).toBe(2); + expect(r.screen).toBe(3); + expect(r.shape).toBe("2+3"); + }); + + it("puts everyone on the last line when there is no 12 unit gap anywhere", () => { + const ours = [at("A", 20, 30), at("B", 25, 50), at("C", 30, 70), at("D", 34, 50)]; + const r = classifyRestDefence(ours, 60); + expect(r.lastLine).toBe(4); + expect(r.screen).toBe(0); + expect(r.shape).toBe("4+0"); + }); + + it("splits at the FIRST gap wider than 12, so the deepest cluster is the last line", () => { + const ours = [at("A", 10, 30), at("B", 12, 60), at("C", 40, 45), at("D", 55, 50)]; + const r = classifyRestDefence(ours, 80); + expect(r.lastLine).toBe(2); + expect(r.screen).toBe(2); + }); + + it("treats a gap of exactly 12 as the same line, so the split needs a real gap", () => { + const ours = [at("A", 20, 30), at("B", 32, 60)]; + expect(classifyRestDefence(ours, 90).shape).toBe("2+0"); + expect(classifyRestDefence([at("A", 20, 30), at("B", 32.5, 60)], 90).shape).toBe("1+1"); + }); + + it("excludes anyone level with or ahead of the ball", () => { + const ours = [at("A", 20, 50), at("B", 60, 50), at("C", 70, 50)]; + expect(classifyRestDefence(ours, 60).behindBall).toBe(1); + }); + + it("falls back to the centroid of our three most advanced when no ball is set", () => { + const ours = [ + at("CB1", 20, 40), + at("CB2", 20, 60), + at("6", 40, 50), + at("LW", 80, 15), + at("ST", 85, 50), + at("RW", 90, 85), + ]; + // Three most advanced are 80, 85, 90; centroid x = 85. Four players sit behind it, + // and the first gap wider than 12 falls between the 20s and the 40. + const r = classifyRestDefence(ours, null); + expect(r.behindBall).toBe(4); + expect(r.shape).toBe("2+2"); + }); + + it("uses every player for the fallback centroid when fewer than three exist", () => { + const r = classifyRestDefence([at("A", 10, 50), at("B", 50, 50)], null); + expect(r.behindBall).toBe(1); // centroid x = 30 + expect(r.shape).toBe("1+0"); + }); + + it("reports zeros when nobody is behind the ball", () => { + expect(classifyRestDefence([at("ST", 90, 50)], 10)).toEqual({ + shape: "0+0", + behindBall: 0, + lastLine: 0, + screen: 0, + }); + }); + + it("is stable when two players share an x, breaking the tie by slot", () => { + const a = classifyRestDefence([at("Z", 20, 30), at("A", 20, 70), at("M", 50, 50)], 70); + const b = classifyRestDefence([at("A", 20, 70), at("M", 50, 50), at("Z", 20, 30)], 70); + expect(a).toEqual(b); + }); + + it("does not mutate the caller's array", () => { + const ours = [at("C", 40, 50), at("A", 10, 50), at("B", 20, 50)]; + classifyRestDefence(ours, 80); + expect(ours.map((o) => o.slot)).toEqual(["C", "A", "B"]); + }); +}); + +// --------------------------------------------------------------------------- +// 6. buildRead. +// --------------------------------------------------------------------------- + +describe("buildRead", () => { + function zone(zoneKey: string, ours: number, theirs: number, anchorX: number) { + return countZone( + { zoneKey, kind: "circle", centre: { x: anchorX, y: 50 }, radius: 200 }, + Array.from({ length: ours }, (_, i) => at(`o${zoneKey}${i}`, anchorX, 50)), + Array.from({ length: theirs }, (_, i) => at(`t${zoneKey}${i}`, anchorX, 50)) + ); + } + + const K = SUPERIORITY_ZONE_KEYS; + + it("picks the spare man from the highest positive delta and the shortage from the lowest", () => { + const zones = [ + zone(K.firstLine, 4, 2, 15), + zone(K.midfieldBox, 5, 3, 50), + zone(K.flankCorridorLeft, 1, 3, 40), + zone(K.lastLine, 2, 4, 85), + ]; + const read = buildRead(zones, null); + // Both positives are +2, so the tie breaks toward our own goal: anchorX 15 wins. + expect(read.spare?.zoneKey).toBe(K.firstLine); + // Both negatives are -2, so the same tie-break applies: anchorX 40 wins. + expect(read.short?.zoneKey).toBe(K.flankCorridorLeft); + }); + + it("breaks a spare-man tie toward our own goal (the lower anchorX)", () => { + const zones = [zone(K.lastLine, 3, 1, 85), zone(K.firstLine, 3, 1, 15)]; + expect(buildRead(zones, null).spare?.zoneKey).toBe(K.firstLine); + }); + + it("breaks a shortage tie toward our own goal too", () => { + const zones = [zone(K.lastLine, 1, 3, 85), zone(K.firstLine, 1, 3, 15)]; + expect(buildRead(zones, null).short?.zoneKey).toBe(K.firstLine); + }); + + it("returns null for spare or short when there is nothing to say", () => { + const read = buildRead([zone(K.midfieldBox, 3, 3, 50)], null); + expect(read.spare).toBeNull(); + expect(read.short).toBeNull(); + }); + + it("handles an empty zone list", () => { + const read = buildRead([], null); + expect(read.spare).toBeNull(); + expect(read.short).toBeNull(); + expect(read.route).toBe("over"); + expect(read.seededCard).toBeNull(); + }); + + describe("route inference when no card is seeded", () => { + it("infers THROUGH when we are up in the midfield box", () => { + const read = buildRead([zone(K.midfieldBox, 5, 3, 50)], null); + expect(read.route).toBe("through"); + expect(read.routeInferred).toBe(true); + }); + + it("infers AROUND when the midfield box is not ours but a flank corridor is", () => { + const zones = [zone(K.midfieldBox, 3, 3, 50), zone(K.flankCorridorRight, 2, 1, 50)]; + expect(buildRead(zones, null).route).toBe("around"); + }); + + it("infers AROUND from the left corridor as well as the right", () => { + const zones = [zone(K.midfieldBox, 2, 4, 50), zone(K.flankCorridorLeft, 2, 1, 50)]; + expect(buildRead(zones, null).route).toBe("around"); + }); + + it("infers OVER when neither the box nor either flank is ours", () => { + const zones = [ + zone(K.midfieldBox, 2, 4, 50), + zone(K.flankCorridorLeft, 1, 2, 50), + zone(K.flankCorridorRight, 1, 2, 50), + ]; + expect(buildRead(zones, null).route).toBe("over"); + }); + + it("prefers THROUGH over AROUND when both are available", () => { + const zones = [zone(K.midfieldBox, 5, 3, 50), zone(K.flankCorridorLeft, 3, 1, 50)]; + expect(buildRead(zones, null).route).toBe("through"); + }); + }); + + describe("a seeded card wins", () => { + const card: FormationMatchup = { + ours_code: "433", + theirs_code: "442", + our_edges: ["Their two banks leave the half-spaces open."], + their_edges: ["Two strikers on your two centre backs."], + route: "Third man into the half-space, then in behind the fullback.", + route_kind: "around", + }; + + it("takes the seeded route_kind even when inference would say otherwise", () => { + const read = buildRead([zone(K.midfieldBox, 5, 3, 50)], card); + expect(read.route).toBe("around"); + expect(read.routeInferred).toBe(false); + expect(read.seededCard).toBe(card); + }); + + it("still computes spare and short from the live zones", () => { + const read = buildRead([zone(K.midfieldBox, 5, 3, 50)], card); + expect(read.spare?.zoneKey).toBe(K.midfieldBox); + }); + }); + + it("is deterministic and does not mutate its input order", () => { + const zones = [zone(K.midfieldBox, 5, 3, 50), zone(K.lastLine, 1, 3, 85)]; + const before = zones.map((z) => z.zoneKey); + expect(buildRead(zones, null)).toEqual(buildRead(zones, null)); + expect(zones.map((z) => z.zoneKey)).toEqual(before); + }); +}); + +describe("SUPERIORITY_ZONE_KEYS matches the doc 06 section 2.3 zone_key column", () => { + it("names all six zones", () => { + expect(Object.values(SUPERIORITY_ZONE_KEYS).sort()).toEqual([ + "counterpress_ring", + "first_line", + "flank_corridor_left", + "flank_corridor_right", + "last_line", + "midfield_box", + ]); + }); +}); diff --git a/frontend/src/board/superiority.ts b/frontend/src/board/superiority.ts new file mode 100644 index 0000000..7059d35 --- /dev/null +++ b/frontend/src/board/superiority.ts @@ -0,0 +1,411 @@ +// The superiority engine (T-104, doc 06 section 4). Pure, deterministic, +// framework free: no React, no DOM, no network, no clock, no randomness. It is +// called on every drag frame, so it allocates modestly and never sorts what it +// does not have to. +// +// LANDSCAPE MODEL COORDS THROUGHOUT (CLAUDE.md rule 8). x runs 0-100 from our +// own goal toward the attacking goal, y runs 0-100 top to bottom. Opponents are +// mirrored into OUR frame by mirrorOpponent before they reach any counting +// function, so the whole engine reasons in one frame of reference. The word +// "orientation" does not appear in this file, and it must not: orientation is +// applied by coords.ts at render time and nowhere else. +// +// DETERMINISM. Verdicts compare integer counts, so they are exact. Nothing here +// reads Date.now(), Math.random(), or any ambient state. Same input, same output, +// every frame, which is what makes the board's warnings stable while a coach +// nudges a token by a pixel. +// +// The engine does NOT fetch zones or matchup cards. They arrive as arguments. +// That is what lets all of this be tested before T-101 and T-103 exist. + +import { distanceSq, pointInCircle, pointInPolygon } from "./geometry"; +import { cellAt, laneAt, lineAt } from "./grid"; +import type { + FormationMatchup, + FreeMan, + MatchupRead, + Pt, + RestDefence, + RouteKind, + SlotPos, + SuperiorityKind, + SuperiorityZone, + Verdict, + ZoneCount, +} from "./superiorityTypes"; + +// pointInPolygon and pointInCircle are implemented in geometry.ts, next to the +// segment maths the lane graph already uses, and re-exported here so callers can +// take the whole doc 06 section 4 API from one module. +export { pointInCircle, pointInPolygon } from "./geometry"; +export type * from "./superiorityTypes"; + +/** The six rondo zones of doc 06 section 2.3. Route inference names three of + * them, so the strings live in one place rather than inline in the logic. */ +export const SUPERIORITY_ZONE_KEYS = { + firstLine: "first_line", + midfieldBox: "midfield_box", + flankCorridorLeft: "flank_corridor_left", + flankCorridorRight: "flank_corridor_right", + lastLine: "last_line", + counterpressRing: "counterpress_ring", +} as const; + +/** Doc 06 section 4: a free man has no opponent within 8 model units. */ +export const DEFAULT_PRESS_RADIUS = 8; + +/** Doc 06 section 4: rest defence splits at a 12 unit gap in x. */ +export const REST_DEFENCE_LINE_GAP = 12; + +/** Doc 06 section 4: the fallback ball line is the centroid of this many of our + * most advanced players, used when no ball is placed. */ +export const BALL_FALLBACK_ADVANCED_COUNT = 3; + +function posOf(p: SlotPos): Pt { + return { x: p.x, y: p.y }; +} + +// --------------------------------------------------------------------------- +// 1. Opponent placement +// --------------------------------------------------------------------------- + +/** + * Place an opponent by rotating 180 degrees about the pitch centre, so their + * attacking direction is the reverse of ours. Their goalkeeper, authored at + * x = 5 in their own frame, lands at x = 95 in ours: in front of our forwards, + * which is where a goalkeeper we are attacking belongs. + * + * INVOLUTIVE, exactly: mirrorOpponent(mirrorOpponent(p)) deep-equals p for every + * integer input, because 100 - (100 - n) is exact in IEEE 754 whenever n is an + * integer in this range. That property is what lets the board round-trip an + * opponent shape through the mirror without drift accumulating over a session of + * toggling the opposition on and off. It is tested across the full 101 by 101 + * integer grid, not on a few sample points. + * + * It deliberately does NOT clamp. Clamping would break involutivity, and a + * caller who wants a token kept on the pitch has clampModel in coords.ts. + */ +export function mirrorOpponent(p: Pt): Pt { + return { x: 100 - p.x, y: 100 - p.y }; +} + +// --------------------------------------------------------------------------- +// 2. Zone containment and counting +// --------------------------------------------------------------------------- + +function inZone(zone: SuperiorityZone, p: Pt): boolean { + return zone.kind === "circle" + ? pointInCircle(p, zone.centre, zone.radius) + : pointInPolygon(p, zone.polygon); +} + +function anchorXOf(zone: SuperiorityZone): number { + if (zone.kind === "circle") return zone.centre.x; + if (zone.polygon.length === 0) return 0; + let sum = 0; + for (const v of zone.polygon) sum += v.x; + return sum / zone.polygon.length; +} + +function verdictOf(delta: number): Verdict { + if (delta > 0) return "superiority"; + if (delta < 0) return "inferiority"; + return "parity"; +} + +/** + * Count both sides inside one zone and turn the counts into a coaching verdict. + * + * `freeSlots` is optional and holds the slots findFreeMen has already judged + * free. It is what lets a zone at PARITY report positional superiority: doc 06 + * section 2.1 defines positional as "same numbers, better placed", so a zone we + * are not winning on bodies but where one of ours is unmarked between their + * lines is a positional edge, and the card has to be able to say which + * superiority it is talking about. Above parity the edge is already numerical + * and stays labelled that way; below parity there is no superiority to claim. + * + * Callers who have not run findFreeMen simply omit the argument, which keeps the + * three argument signature doc 06 sketches valid. + */ +export function countZone( + zone: SuperiorityZone, + ours: SlotPos[], + theirs: SlotPos[], + freeSlots?: ReadonlySet +): ZoneCount { + let oursIn = 0; + let theirsIn = 0; + let hasFreeMan = false; + + for (const p of ours) { + if (!inZone(zone, p)) continue; + oursIn += 1; + if (freeSlots !== undefined && freeSlots.has(p.slot)) hasFreeMan = true; + } + for (const p of theirs) { + if (inZone(zone, p)) theirsIn += 1; + } + + const delta = oursIn - theirsIn; + let superiorityKind: SuperiorityKind | null = null; + if (delta > 0) superiorityKind = "numerical"; + else if (delta === 0 && hasFreeMan) superiorityKind = "positional"; + + return { + zoneKey: zone.zoneKey, + ours: oursIn, + theirs: theirsIn, + delta, + label: `${oursIn}v${theirsIn}`, + verdict: verdictOf(delta), + superiorityKind, + anchorX: anchorXOf(zone), + }; +} + +// --------------------------------------------------------------------------- +// 3. Positional superiority: the free man +// --------------------------------------------------------------------------- + +/** + * Coach-facing copy for a free man. Doc 06 section 2.1: every card the engine + * emits must NAME which superiority it is talking about, because that is the + * transferable coaching language. So this sentence leads with the name, locates + * the player in the grid a coach can actually see on the overlay, and finishes + * with what to do about it. No analytics voice, no score, no em dash. + */ +function freeManCopy(laneLabel: string, lineLabel: string): string { + return ( + `Positional superiority: alone in the ${laneLabel.toLowerCase()}, ` + + `${lineLabel.toLowerCase()}, with no opponent close enough to press. ` + + `Find that pass and the receiver turns forward.` + ); +} + +/** + * Find our free men: positional superiority made computable. + * + * Doc 06 section 4 defines a free man as one of ours alone in a lane cell + * between two of their horizontal lines, with no opponent in the same cell and + * none within `pressRadius`. Written out as four conditions, all of which must + * hold: + * + * 1. ALONE IN THE CELL. No teammate shares the grid cell. Two of ours in the + * same pocket is an overload, which is a different (numerical) reading. + * 2. NO OPPONENT IN THE CELL. Someone is standing in the pocket, so it is + * occupied even if they are not tight. + * 3. NOT PRESSED. Every opponent is strictly further than pressRadius away. + * Exactly at the radius counts as pressing: the boundary belongs to the + * defender, which is the conservative call and keeps the engine from + * promising a coach a free man who is about to be closed down. + * 4. BETWEEN TWO OF THEIR LINES. At least one opponent is behind him (smaller + * x) and at least one ahead (larger x). + * + * Condition 4 needs a word, because doc 06 says "between two of their horizontal + * lines" without saying whose lines to measure. Measuring against our own grid + * bands would be wrong, since the grid is fixed to the pitch and their block is + * not: a deep block and a high press produce identical grid bands. Measuring + * against THEIR actual positions is the football meaning, and it is also what + * makes the striker case come out right. A forward beyond their entire back line + * has nobody behind him, so he is not in a pocket between two lines, he is + * offside or through. He is correctly not reported as a free man. + * + * Returns free men in the input order of `ours`, so the output is stable frame + * to frame and the UI can key on it without reordering. + */ +export function findFreeMen( + ours: SlotPos[], + theirs: SlotPos[], + pressRadius: number = DEFAULT_PRESS_RADIUS +): FreeMan[] { + const result: FreeMan[] = []; + if (ours.length === 0 || theirs.length === 0) return result; + + const pressSq = pressRadius * pressRadius; + + // Cell keys are computed once per player rather than per pair. + const ourCells = ours.map((p) => cellAt(posOf(p))); + const theirCellKeys = theirs.map((p) => cellAt(posOf(p)).key); + + for (let i = 0; i < ours.length; i += 1) { + const me = ours[i]; + const cell = ourCells[i]; + + // 1. Alone in the cell. + let teammateShares = false; + for (let j = 0; j < ours.length; j += 1) { + if (j !== i && ourCells[j].key === cell.key) { + teammateShares = true; + break; + } + } + if (teammateShares) continue; + + // 2, 3 and 4 in one pass over their shape. + const mePos = posOf(me); + let opponentShares = false; + let pressed = false; + let someoneBehind = false; + let someoneAhead = false; + + for (let j = 0; j < theirs.length; j += 1) { + const opp = theirs[j]; + if (theirCellKeys[j] === cell.key) { + opponentShares = true; + break; + } + if (distanceSq(mePos, posOf(opp)) <= pressSq) { + pressed = true; + break; + } + if (opp.x < me.x) someoneBehind = true; + else if (opp.x > me.x) someoneAhead = true; + } + if (opponentShares || pressed) continue; + if (!someoneBehind || !someoneAhead) continue; + + result.push({ + slot: me.slot, + cell, + whyItMatters: freeManCopy(laneAt(me.y).label, lineAt(me.x).label), + }); + } + + return result; +} + +// --------------------------------------------------------------------------- +// 4. Rest defence +// --------------------------------------------------------------------------- + +/** + * Classify our rest defence: how many are behind the ball, and how they split + * into a last line and a screen in front of it. + * + * The ball line is `ballX`. Pass null when no ball is placed and the fallback of + * doc 06 section 4 applies: the centroid x of our three most advanced players + * (or of everyone, when we have fewer than three). Doc 06 sketches the parameter + * as a plain number; null is accepted because "no ball is set" has to be + * expressible for the fallback to ever run. + * + * Behind means STRICTLY behind, x < ballLine, so the player on the ball is not + * counted as part of the cover behind it. + * + * The split: sort those behind the ball by x, then cut at the FIRST gap wider + * than REST_DEFENCE_LINE_GAP. Everything deeper than the cut is the last line, + * everything in front of it is the screen. First gap, not widest, because the + * deepest cluster is the last line by definition and the question is only where + * that cluster ends. A gap of exactly 12 is not a split: two players 12 apart + * are still covering for each other, and requiring a strictly wider gap keeps a + * shape from flickering between 3+2 and 4+1 while a coach drags a token. + * + * `shape` always carries both numbers, so "4+0" is emitted rather than "4". It + * is engine output that a surface formats, and a parseable string beats a pretty + * one that sometimes has one number and sometimes two. + */ +export function classifyRestDefence(ours: SlotPos[], ballX: number | null): RestDefence { + const ballLine = ballX === null ? fallbackBallLine(ours) : ballX; + + const behind = ours.filter((p) => p.x < ballLine); + if (behind.length === 0) { + return { shape: "0+0", behindBall: 0, lastLine: 0, screen: 0 }; + } + + // Copy before sorting: never mutate the caller's array. Slot breaks x ties so + // the result cannot depend on the order the caller happened to build the list. + const sorted = [...behind].sort((a, b) => (a.x - b.x) || (a.slot < b.slot ? -1 : a.slot > b.slot ? 1 : 0)); + + let splitAt = sorted.length; // no gap found: everyone is the last line + for (let i = 1; i < sorted.length; i += 1) { + if (sorted[i].x - sorted[i - 1].x > REST_DEFENCE_LINE_GAP) { + splitAt = i; + break; + } + } + + const lastLine = splitAt; + const screen = sorted.length - splitAt; + return { shape: `${lastLine}+${screen}`, behindBall: sorted.length, lastLine, screen }; +} + +/** Centroid x of our most advanced players, the ball line when no ball is set. */ +function fallbackBallLine(ours: SlotPos[]): number { + if (ours.length === 0) return 0; + const byX = [...ours].sort((a, b) => b.x - a.x); + const n = Math.min(BALL_FALLBACK_ADVANCED_COUNT, byX.length); + let sum = 0; + for (let i = 0; i < n; i += 1) sum += byX[i].x; + return sum / n; +} + +// --------------------------------------------------------------------------- +// 5. The read +// --------------------------------------------------------------------------- + +/** + * Route inference, used only when no matchup card is seeded. Doc 06 section 4 + * states it so it is reproducible: midfield box ours means THROUGH, else either + * flank corridor ours means AROUND, else OVER. Note the priority is deliberate, + * not incidental: if we own the middle we play through it even when a flank is + * also free, because the middle is the shorter route to goal. + */ +function inferRoute(zones: ZoneCount[]): RouteKind { + let flankIsOurs = false; + for (const z of zones) { + if (z.zoneKey === SUPERIORITY_ZONE_KEYS.midfieldBox && z.delta > 0) return "through"; + if ( + (z.zoneKey === SUPERIORITY_ZONE_KEYS.flankCorridorLeft || + z.zoneKey === SUPERIORITY_ZONE_KEYS.flankCorridorRight) && + z.delta > 0 + ) { + flankIsOurs = true; + } + } + return flankIsOurs ? "around" : "over"; +} + +/** + * Pick the extreme zone. `sign` is +1 for the spare man (largest positive delta) + * and -1 for the shortage (most negative delta). + * + * Ties break toward our own goal, meaning the smaller anchorX wins. That is a + * coaching decision, not an arbitrary one: of two equal reads, the one nearer + * our own goal is the one that decides whether we can build at all, and the one + * whose failure costs a goal rather than a chance. Remaining ties keep the first + * zone in input order, so the result is fully determined by the input. + */ +function extremeZone(zones: ZoneCount[], sign: 1 | -1): ZoneCount | null { + let best: ZoneCount | null = null; + for (const z of zones) { + if (sign === 1 ? z.delta <= 0 : z.delta >= 0) continue; + if (best === null) { + best = z; + continue; + } + const better = sign === 1 ? z.delta > best.delta : z.delta < best.delta; + const tied = z.delta === best.delta; + if (better || (tied && z.anchorX < best.anchorX)) best = z; + } + return best; +} + +/** + * Assemble the three-step coaching read: where we are spare, where we are short, + * and how the ball gets there. A seeded card's route_kind always wins over + * inference; `routeInferred` tells the surface which it got, because doc 06 + * requires an inferred route to be labelled and phrased more softly than a + * seeded one. The engine will not fake confidence it does not have. + */ +export function buildRead(zones: ZoneCount[], seeded: FormationMatchup | null): MatchupRead { + return { + spare: extremeZone(zones, 1), + short: extremeZone(zones, -1), + route: seeded ? seeded.route_kind : inferRoute(zones), + routeInferred: !seeded, + seededCard: seeded, + }; +} + +// Re-exported so a caller can take the full doc 06 section 4 engine from this +// one module without having to know that the grid lives next door. +export { JDP_GRID, JDP_OCCUPANCY_LIMITS, cellAt, cellKey, gridOccupancy } from "./grid"; diff --git a/frontend/src/board/superiorityTypes.ts b/frontend/src/board/superiorityTypes.ts new file mode 100644 index 0000000..5507a96 --- /dev/null +++ b/frontend/src/board/superiorityTypes.ts @@ -0,0 +1,163 @@ +// Shared types for the superiority engine (T-104, doc 06 section 4). Types only, +// no runtime code, so grid.ts and superiority.ts can both depend on this without +// an import cycle. Same split as animationTypes.ts alongside playback.ts. +// +// EVERY coordinate here is a LANDSCAPE MODEL coordinate (CLAUDE.md rule 8): +// x 0-100 from our own goal toward the attacking goal, y 0-100 top to bottom. +// Orientation is a render concern and appears nowhere in this engine. +// +// The seeded shapes (rondo zone polygons, formation matchup cards) are built by +// T-101 and T-103. The engine deliberately does not fetch them: they arrive as +// INPUTS, which is what lets the whole engine be unit tested before either +// ticket lands. When the API shapes exist, map them onto these types at the +// boundary rather than threading network types through the maths. + +import type { ModelPoint } from "./coords"; + +/** Doc 06 section 4 calls this `Pt`. It is the board's ModelPoint, unchanged. */ +export type Pt = ModelPoint; + +/** One slot of a formation at a position. `slot` is the stable identity across phases. */ +export interface SlotPos { + slot: string; + position_code: string; + x: number; + y: number; +} + +// --------------------------------------------------------------------------- +// Zones +// --------------------------------------------------------------------------- + +/** + * A zone to count inside. Mirrors `rondo_zones.zone_kind` from doc 06 section 3.1: + * five seeded polygons plus the counterpress ring, which is a circle that follows + * the ball instead of sitting still on the pitch. + */ +export type SuperiorityZone = + | { zoneKey: string; kind: "polygon"; polygon: Pt[] } + | { zoneKey: string; kind: "circle"; centre: Pt; radius: number }; + +export type Verdict = "superiority" | "parity" | "inferiority"; + +/** Numerical is more bodies. Positional is the same bodies, better placed (doc 06 section 2.1). */ +export type SuperiorityKind = "numerical" | "positional"; + +export interface ZoneCount { + zoneKey: string; + ours: number; + theirs: number; + /** ours - theirs. Integer, so every verdict below is exact. */ + delta: number; + /** The ratio chip, for example '4v2'. */ + label: string; + verdict: Verdict; + superiorityKind: SuperiorityKind | null; + /** + * Representative x of the zone, in model coords. NOT in the doc 06 sketch of + * this type, added because buildRead has to break ties "toward our own goal" + * and a bare zoneKey carries no geometry to do that with. Polygon: mean vertex + * x. Circle: centre x. + */ + anchorX: number; +} + +// --------------------------------------------------------------------------- +// The JdP grid (doc 06 section 2.2) +// --------------------------------------------------------------------------- + +export type LaneKey = + | "left_wing" + | "left_half_space" + | "centre" + | "right_half_space" + | "right_wing"; + +export type LineKey = + | "own_build" + | "first_line" + | "middle" + | "between_the_lines" + | "last_line"; + +/** One band of the grid: a lane across y, or a horizontal line across x. */ +export interface GridBand { + key: K; + /** Coach-facing name, used verbatim in warning copy. */ + label: string; + /** Inclusive lower edge. */ + min: number; + /** Exclusive upper edge, except on the final band where it is inclusive. */ + max: number; +} + +export interface GridCell { + lane: LaneKey; + line: LineKey; + /** Stable composite key, see cellKey(). */ + key: string; +} + +export type GridBreachKind = "lane_over" | "line_over" | "wide_lane_shared"; + +export interface GridBreach { + kind: GridBreachKind; + /** + * The band the check is about: a LaneKey for lane_over and wide_lane_shared, a + * LineKey for line_over. Both of those guidelines are whole-band rules rather + * than single-cell rules, so naming the band is what makes the warning useful. + */ + cell: string; + count: number; + slots: string[]; +} + +export interface FreeMan { + slot: string; + cell: GridCell; + /** Coach-facing copy. Names which superiority this is (doc 06 section 2.1). */ + whyItMatters: string; +} + +// --------------------------------------------------------------------------- +// Rest defence and the read +// --------------------------------------------------------------------------- + +export interface RestDefence { + /** '3+2', last line plus screen. Always both numbers, so it is parseable. */ + shape: string; + behindBall: number; + lastLine: number; + screen: number; +} + +export type RouteKind = "through" | "around" | "over"; + +/** + * A seeded `formation_matchups` row (doc 06 section 3.1), in the shape the engine + * needs. T-103 owns the wire format; the engine reads only `route_kind` and + * passes the rest through untouched for the card to render. + */ +export interface FormationMatchup { + ours_code: string; + theirs_code: string; + our_edges: string[]; + their_edges: string[]; + route: string; + route_kind: RouteKind; +} + +export interface MatchupRead { + /** Highest positive delta. Tie broken toward our own goal. */ + spare: ZoneCount | null; + /** Lowest negative delta. Tie broken toward our own goal. */ + short: ZoneCount | null; + route: RouteKind; + /** + * True when `route` came from the inference rule rather than a seeded card. + * Doc 06 section 4 requires the UI to say so and to soften the language, which + * it cannot do unless the engine tells it. Not in the doc's type sketch. + */ + routeInferred: boolean; + seededCard: FormationMatchup | null; +} diff --git a/seeds/rondo_zones.json b/seeds/rondo_zones.json index 5b0ba6e..1bb9ece 100644 --- a/seeds/rondo_zones.json +++ b/seeds/rondo_zones.json @@ -29,7 +29,19 @@ }, { "formation_code": "433", - "zone_key": "flank_corridor", + "zone_key": "flank_corridor_left", + "rondo_name": "2v1 to 2v2 (the flank corridor)", + "teaches": "Winger and fullback against their fullback, with a tracking winger: every overlap or underlap decision is this corridor rondo in its pure form.", + "polygon_json": [ + {"x": 20, "y": 0}, {"x": 90, "y": 0}, {"x": 90, "y": 25}, {"x": 20, "y": 25} + ], + "trains_pattern_codes": ["A1", "A2", "F1"], + "source_ref": "bible:3G.2", + "content_version": "1.0.0" + }, + { + "formation_code": "433", + "zone_key": "flank_corridor_right", "rondo_name": "2v1 to 2v2 (the flank corridor)", "teaches": "Winger and fullback against their fullback, with a tracking winger: every overlap or underlap decision is this corridor rondo in its pure form.", "polygon_json": [