diff --git a/.gitignore b/.gitignore index b483382..b8e56f6 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ test-results/ *.db-shm .DS_Store *.egg-info/ +# scripts/build_logo_assets.py review composites: not shipped, regenerated +# on every run. +build/logo_review/ diff --git a/Makefile b/Makefile index 164099b..3220162 100644 --- a/Makefile +++ b/Makefile @@ -3,6 +3,44 @@ VENV := .venv PY := $(VENV)/bin/python +# T-109: derive a stable, distinct POP_WEB_PORT / POP_API_PORT pair from +# this checkout's own path, so `make dev` / `make e2e` / `make verify` no +# longer collide when several ticket worktrees run them at the same time. +# Before this, both defaulted to 5173/8000 everywhere (scripts/dev.sh's +# fallback, playwright.config.ts's fallback), so a second worktree's +# `make verify` would find the FIRST worktree's dev server already +# listening (reuseExistingServer is true outside CI) and quietly test the +# wrong worktree's code against the wrong worktree's database. That +# doesn't fail loudly, it looks exactly like a flaky assertion, and it +# cost this epic several false diagnoses (doc 06 section 6, T-109). +# +# `?=` means an explicit `POP_WEB_PORT=7673 POP_API_PORT=10500 make +# verify` (or an exported value from the shell) always wins over this +# default, so the manual override every ticket in this epic already +# relied on keeps working unchanged, including running two worktrees on +# the SAME two ports on purpose (e.g. one at a time, by hand). +# +# CURDIR is make's own absolute working directory (set once at startup, +# unaffected by any `cd` inside a recipe), so it is exactly the +# per-worktree checkout path. cksum is POSIX and present on both macOS +# and the Linux CI runner, so this needs no new dependency. Folding the +# checksum into 0-999 keeps the derived ports inside a low-collision, +# non-privileged range (5173-6172 for web, 8000-8999 for api) without a +# real port-availability probe, which is unnecessary here: CI only ever +# runs one checkout at a time, so its derived pair is simply "some +# deterministic port instead of 5173", never a coordination problem. +# +# `printf '%s'`, not `echo -n`: make always runs shell functions through +# /bin/sh regardless of the caller's login shell, and /bin/sh's builtin +# echo does not honour -n on macOS OR on Debian/Ubuntu's dash (the CI +# runner's /bin/sh), so `echo -n "$(CURDIR)"` hashes the literal 4 +# characters "-n " glued onto the path instead of the path alone. Still +# deterministic, but needlessly fragile; printf has no such flag ambiguity +# in any POSIX shell. +WORKTREE_OFFSET := $(shell printf '%s' "$(CURDIR)" | cksum | awk '{print $$1 % 1000}') +export POP_WEB_PORT ?= $(shell echo $$(( 5173 + $(WORKTREE_OFFSET) ))) +export POP_API_PORT ?= $(shell echo $$(( 8000 + $(WORKTREE_OFFSET) ))) + bootstrap: python3 -m venv $(VENV) $(VENV)/bin/pip install --quiet --upgrade pip @@ -30,9 +68,12 @@ test: e2e: npx playwright test +# Static source guards. check_palette.py is here rather than in the vitest +# suite because it reads the shipped CSS text, which vitest stubs out. check-copy: $(PY) scripts/check_copy.py $(PY) scripts/validate_seeds.py + $(PY) scripts/check_palette.py # The Brief section 3 permission table, every row (backend/tests/test_permissions.py). # `make test` already runs it, but this target also fails when a row is diff --git a/assets/brand/patternsofplaylogo.png b/assets/brand/patternsofplaylogo.png new file mode 100644 index 0000000..ec8952d Binary files /dev/null and b/assets/brand/patternsofplaylogo.png differ diff --git a/backend/app/main.py b/backend/app/main.py index aad402e..2ea1f83 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -13,6 +13,7 @@ roster, sessions, suggestions, + tactics, teams, whiteboard, ) @@ -27,6 +28,7 @@ app.include_router(formations.router) app.include_router(identity.router) app.include_router(sessions.router) +app.include_router(tactics.router) @app.get("/api/health") diff --git a/backend/app/models/formations.py b/backend/app/models/formations.py index cccfc7d..47a2015 100644 --- a/backend/app/models/formations.py +++ b/backend/app/models/formations.py @@ -53,7 +53,7 @@ class RondoZone(Base): formation_code: Mapped[str] = mapped_column(ForeignKey("formations.code"), primary_key=True) # first_line | midfield_box | flank_corridor_left | flank_corridor_right - # | last_line | counterpress + # | last_line | counterpress_ring 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) diff --git a/backend/app/routers/formations.py b/backend/app/routers/formations.py index e8ff606..796afe8 100644 --- a/backend/app/routers/formations.py +++ b/backend/app/routers/formations.py @@ -23,19 +23,24 @@ FORMATION_ORDER = ("433", "4231", "442", "352", "343", "541") # 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. 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). +# counterpress moment); T-103 seeds all six zones on all six formations. +# 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). +# +# The last key is `counterpress_ring`, doc 06 section 2.3's name for it. +# It read `counterpress` until T-112, the pre-0007 zone key, which no seed +# has written since T-103. That stale entry sorted the ring to the end by +# ACCIDENT (an unknown key falls to len(order)) rather than by intent, and +# would have silently mis-sorted the moment a seventh zone existed. ZONE_ORDER = ( "first_line", "midfield_box", "flank_corridor_left", "flank_corridor_right", "last_line", - "counterpress", + "counterpress_ring", ) @@ -79,6 +84,7 @@ def list_formations( FormationPositionOut( slot=p["slot"], position_code=p["position_code"], + slot_family=p["slot_family"], x=p["x"], y=p["y"], ) @@ -95,6 +101,15 @@ def list_formations( teaches=z.teaches, polygon=z.polygon_json, trains_pattern_codes=z.trains_pattern_codes, + # doc 06 section 2.3 (T-112). canonical_rondo is the + # no-opposition fallback label; zone_kind and radius + # are what tell the Rondo Map that the counterpress + # ring is a circle around the ball rather than the + # polygon seeded alongside it, which only bounds the + # half of the pitch the ring is coached in. + canonical_rondo=z.canonical_rondo, + zone_kind=z.zone_kind, + radius=z.radius, ) for z in zones ], diff --git a/backend/app/routers/tactics.py b/backend/app/routers/tactics.py new file mode 100644 index 0000000..049e282 --- /dev/null +++ b/backend/app/routers/tactics.py @@ -0,0 +1,593 @@ +"""Tactics Lab API (Epic T-100, doc 06 sections 3.2, 5.3, 6; T-108): +formation phases, formation matchups, rotations, position archetypes, the +coach-only archetype suggestion ranking, and a team's own saved formation +setups (team_formations / team_formation_slots). + +Two worlds, same split doc 06 section 3 draws and app/models/tactics.py +already documents: + - library world (FormationPhase, RotationSystem, PositionArchetype, + FormationMatchup): no team_id, seeded by T-102/T-103 (empty today), + read-only, visible to BOTH roles, same shape as app/routers/formations.py + and app/routers/identity.py (get_current_user, not a team scope). + - team world (TeamFormation direct team_id, TeamFormationSlot transitive + through team_formation_id): every route depends on get_team_scope, + never a client-supplied team_id (CLAUDE.md rule 4). Reads are open to + both roles (roster.py precedent: full roster data is player-viewable, + only the fit-warning-shaped analysis is coach-only); writes + (create/update) are coach-only (require_role_on_team("coach")). + +Coach-only surface (doc 06 section 5.3): GET /api/archetypes/suggest is +the one route that carries footedness notes, attribute-fit reasons, and +work-rate matching, "the why must cite the actual reason ..., not a +score" -- all of it 403s for a player token, tested in +tests/test_tactics_routes.py per this module's own docstring below the +route. + +The scope gap T-108 flagged here (doc 06 never says which of a +formation's eleven slots belong to which unit_balance_rules.unit) is +closed by T-110: `slot_family` is now seeded per slot on +seeds/formations.json, and app/units.py holds the fixed +slot_family-to-unit crosswalk plus the evaluator. POST +/api/formations/{code}/balance below is the coach-only surface for it. +GET /api/archetypes/suggest still ranks on three criteria rather than +four: doc 06 section 5.3's fourth criterion asks whether "the RESULTING +unit passes its balance rules", which needs the other ten slots' current +picks, and that route takes a single slot_family and no formation +context. The balance route evaluates the whole eleven at once instead, +which is how the panel actually uses it (section 5.3: "evaluates +unit_balance_rules live as archetypes change"). +""" + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session + +from app.deps import ( + CurrentMembership, + get_current_membership, + get_current_user, + get_db, + require_role_on_team, +) +from app.models import ( + Formation, + FormationMatchup, + FormationPhase, + Player, + PlayerAttribute, + PositionArchetype, + RotationSystem, + TeamFormation, + TeamFormationSlot, + UnitBalanceRule, + User, +) +from app.schemas import ( + ArchetypeSuggestionOut, + ArchetypeSuggestResponse, + Flank, + FormationMatchupOut, + FormationMatchupResponse, + FormationPhaseOut, + PositionArchetypeOut, + RotationSystemOut, + TeamFormationOut, + TeamFormationSlotOut, + TeamFormationWriteRequest, + UnitBalanceNoteOut, + UnitBalanceRequest, + UnitBalanceResponse, + UnitBalanceUnitOut, +) +from app.scoped import TeamScope, get_team_scope +from app.units import evaluate_unit_balance, units_absent + +router = APIRouter(prefix="/api", tags=["tactics"]) + +# Same fixed-order-for-determinism convention as app/routers/formations.py's +# FORMATION_ORDER/ZONE_ORDER: these vocabularies are doc 06 section 3.1's own +# enumerations, not alphabetized, so a listing reads in the doc's own order +# rather than whatever order sqlite happens to return rows in. +PHASE_ORDER = ("in_possession", "out_of_possession", "rest_defence", "transition") +FAMILY_ORDER = ("first_line", "pivot", "wide", "front_line") + + +def _order_index(value: str, order: tuple[str, ...]) -> int: + try: + return order.index(value) + except ValueError: + return len(order) + + +# --------------------------------------------------------------------------- +# Library world: phases, matchups, rotations, archetypes. Read-only, both +# roles, no team scope (same reasoning as app/routers/formations.py / +# identity.py / library.py: these tables carry no team_id at all). +# --------------------------------------------------------------------------- + + +@router.get("/formations/{code}/phases", response_model=list[FormationPhaseOut]) +def list_formation_phases( + code: str, + _current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +) -> list[FormationPhase]: + if db.get(Formation, code) is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Formation not found") + phases = db.query(FormationPhase).filter(FormationPhase.formation_code == code).all() + phases.sort(key=lambda p: (_order_index(p.phase, PHASE_ORDER), p.variant_code)) + return phases + + +@router.get("/formations/matchup", response_model=FormationMatchupResponse) +def get_formation_matchup( + ours: str = Query(...), + theirs: str = Query(...), + _current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +) -> FormationMatchupResponse: + if db.get(Formation, ours) is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Formation not found: " + ours) + if db.get(Formation, theirs) is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Formation not found: " + theirs) + + # formation_matchups' natural key is normalised (ours_code <= + # theirs_code) at seed time to store one row per UNORDERED pair (doc 06 + # section 3.1: 15 pairs for 6 formations, not 30), so a query in either + # order must resolve to the same row. + row = ( + db.query(FormationMatchup) + .filter( + ( + (FormationMatchup.ours_code == ours) + & (FormationMatchup.theirs_code == theirs) + ) + | ( + (FormationMatchup.ours_code == theirs) + & (FormationMatchup.theirs_code == ours) + ) + ) + .first() + ) + # No swap of our_edges/their_edges/route when the query order is the + # reverse of how the pair was seeded: only one direction's route text + # is authored per pair, and presenting it as the other side's own read + # would misattribute a narrative nobody wrote (see module docstring). + return FormationMatchupResponse( + ours_code=ours, + theirs_code=theirs, + matchup=FormationMatchupOut.model_validate(row) if row is not None else None, + ) + + +@router.get("/rotations", response_model=list[RotationSystemOut]) +def list_rotations( + formation_code: str | None = Query(default=None), + _current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +) -> list[RotationSystem]: + rotations = db.query(RotationSystem).all() + if formation_code is not None: + rotations = [r for r in rotations if formation_code in r.applies_to_formations] + rotations.sort(key=lambda r: (_order_index(r.family, FAMILY_ORDER), r.name)) + return rotations + + +@router.get("/archetypes", response_model=list[PositionArchetypeOut]) +def list_archetypes( + slot_family: str | None = Query(default=None), + _current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +) -> list[PositionArchetype]: + query = db.query(PositionArchetype) + if slot_family is not None: + query = query.filter(PositionArchetype.slot_family == slot_family) + return query.order_by(PositionArchetype.slot_family, PositionArchetype.name).all() + + +# --------------------------------------------------------------------------- +# Archetype suggestion ranking (doc 06 section 5.3), coach-only. +# --------------------------------------------------------------------------- + + +def _label(attribute_key: str) -> str: + return attribute_key.replace("_", " ") + + +def _attribute_reasons(attrs: dict[str, int], key_attribute_keys: list[str]) -> list[str]: + """Top two of the archetype's key attributes the player actually has a + rated value for, highest value first, ties broken by the archetype's + own key order. Cites the real value, never a computed score (doc 06 + section 5.3).""" + pairs = [(key, attrs[key]) for key in key_attribute_keys if key in attrs] + pairs.sort(key=lambda kv: (-kv[1], key_attribute_keys.index(kv[0]))) + return [f"{_label(key)} {value}" for key, value in pairs[:2]] + + +def _foot_fits(preferred_foot: str, side: str | None, foot_hint: str | None) -> bool | None: + """None means "not applicable" (no foot_hint on the archetype, no side + to check against, or a central slot with no side at all): doc 06 + section 5.3's "foot fit against foot_hint and the slot's side".""" + if foot_hint is None or side is None or side == "center": + return None + if preferred_foot == "B": + return True + same_side = (side == "left" and preferred_foot == "L") or ( + side == "right" and preferred_foot == "R" + ) + if foot_hint == "same_side": + return same_side + if foot_hint == "opposite_side": + return not same_side + return True # 'either' + + +def _build_why( + attrs: dict[str, int], archetype: PositionArchetype, foot_fit: bool | None, wr_match: int +) -> str: + clauses: list[str] = [] + reasons = _attribute_reasons(attrs, archetype.key_attribute_keys) + name_lower = archetype.name.lower() + if len(reasons) >= 2: + clauses.append(f"{reasons[0]} and {reasons[1]} fit the {name_lower}") + elif len(reasons) == 1: + clauses.append(f"{reasons[0]} fits the {name_lower}") + + if foot_fit is True and archetype.foot_hint in ("same_side", "opposite_side"): + side_word = "same side" if archetype.foot_hint == "same_side" else "opposite side" + clauses.append(f"footedness on the {side_word} suits this slot") + + if wr_match == 2: + clauses.append("work rate matches the role both ways") + + if not clauses: + clauses.append(f"the closest available fit for the {archetype.slot_family} slot") + return ", ".join(clauses) + + +def _player_attrs(scope: TeamScope, player_id: int) -> dict[str, int]: + rows = ( + scope.query_via(PlayerAttribute, Player, PlayerAttribute.player_id == Player.id) + .filter(PlayerAttribute.player_id == player_id) + .all() + ) + return {row.attribute_key: row.value for row in rows} + + +@router.get("/archetypes/suggest", response_model=ArchetypeSuggestResponse) +def suggest_archetypes( + slot_family: str = Query(...), + player_id: int | None = Query(default=None), + side: Flank | None = Query(default=None), + ctx: CurrentMembership = Depends(require_role_on_team("coach")), + scope: TeamScope = Depends(get_team_scope), + db: Session = Depends(get_db), +) -> ArchetypeSuggestResponse: + candidates = ( + db.query(PositionArchetype).filter(PositionArchetype.slot_family == slot_family).all() + ) + if not candidates: + return ArchetypeSuggestResponse(slot_family=slot_family, player_id=player_id, suggestions=[]) + + if player_id is None: + # Empty roster / no player assigned yet is a first-class state (doc + # 06 section 5.3: "the panel still works with archetypes alone and + # no players assigned"), so this still returns a usable top three, + # just without an attribute-fit why. + top = sorted(candidates, key=lambda a: a.name)[:3] + suggestions = [ + ArchetypeSuggestionOut( + archetype_code=a.code, + archetype_name=a.name, + slot_family=a.slot_family, + why=f"No player assigned yet; a starting option for the {slot_family} slot.", + ) + for a in top + ] + return ArchetypeSuggestResponse( + slot_family=slot_family, player_id=None, suggestions=suggestions + ) + + player = scope.get(Player, player_id) + if player is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Player not found") + + attrs = _player_attrs(scope, player_id) + effective_side = side if side is not None else player.flank + + ranked: list[tuple[int, int, int, PositionArchetype, bool | None]] = [] + for archetype in candidates: + attribute_score = sum(attrs.get(key, 0) for key in archetype.key_attribute_keys) + foot_fit = _foot_fits(player.preferred_foot, effective_side, archetype.foot_hint) + wr_match = (player.awr == archetype.awr_default) + (player.dwr == archetype.dwr_default) + ranked.append((attribute_score, 1 if foot_fit else 0, wr_match, archetype, foot_fit)) + + # doc 06 section 5.3's ranking order, in priority: attribute fit, then + # foot fit, then AWR/DWR match; archetype name breaks any remaining tie + # so the result is deterministic. + ranked.sort(key=lambda r: (-r[0], -r[1], -r[2], r[3].name)) + + suggestions = [ + ArchetypeSuggestionOut( + archetype_code=archetype.code, + archetype_name=archetype.name, + slot_family=archetype.slot_family, + why=_build_why(attrs, archetype, foot_fit, wr_match), + ) + for _score, _foot, wr_match, archetype, foot_fit in ranked[:3] + ] + return ArchetypeSuggestResponse(slot_family=slot_family, player_id=player_id, suggestions=suggestions) + + +# --------------------------------------------------------------------------- +# Unit balance evaluation (doc 06 sections 2.6 / 3.1 / 5.3), coach-only. +# --------------------------------------------------------------------------- + + +@router.post("/formations/{code}/balance", response_model=UnitBalanceResponse) +def evaluate_balance( + code: str, + payload: UnitBalanceRequest, + ctx: CurrentMembership = Depends(require_role_on_team("coach")), + db: Session = Depends(get_db), +) -> UnitBalanceResponse: + """Evaluate the seeded unit_balance_rules against one formation's + current archetype picks. + + POST rather than GET because doc 06 section 5.3 evaluates this "live as + archetypes change", against the personnel panel's UNSAVED state: the + eleven picks are the input and there is no saved row to name yet. It + computes and persists nothing, and touches no team-world table, so + there is no scoped query here to make; `require_role_on_team("coach")` + is what resolves the caller's team, and no team_id ever comes off the + request (CLAUDE.md rule 4). + + Coach-only: doc 06 section 5.3 says unit balance is coach-only "both in + the UI and at the API", the same standing as roster fit warnings, so a + player token gets 403 here rather than an empty list + (tests/test_permissions.py and tests/test_tactics_routes.py). + """ + formation = db.get(Formation, code) + if formation is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Formation not found") + + positions = formation.positions_json or [] + known_slots = {p.get("slot") for p in positions} + assignments: dict[str, str | None] = {} + for slot_in in payload.slots: + if slot_in.slot not in known_slots: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Slot '{slot_in.slot}' is not part of formation {code}", + ) + assignments[slot_in.slot] = slot_in.archetype_code + + codes = [c for c in assignments.values() if c is not None] + archetypes = { + a.code: a + for a in ( + db.query(PositionArchetype).filter(PositionArchetype.code.in_(codes)).all() + if codes + else [] + ) + } + unknown = sorted(set(codes) - set(archetypes)) + if unknown: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Unknown archetype_code: {unknown[0]}", + ) + + rules = db.query(UnitBalanceRule).order_by(UnitBalanceRule.code).all() + evaluations = evaluate_unit_balance(positions, assignments, archetypes, rules) + + return UnitBalanceResponse( + formation_code=code, + units=[ + UnitBalanceUnitOut( + unit=e.unit, + flank=e.flank, + slots=list(e.slots), + assigned_slots=list(e.assigned_slots), + is_complete=e.is_complete, + notes=[ + UnitBalanceNoteOut( + code=n.code, + unit=n.unit, + flank=n.flank, + severity=n.severity, # type: ignore[arg-type] + message=n.message, + slots=list(n.slots), + ) + for n in e.notes + ], + ) + for e in evaluations + ], + units_not_evaluated=units_absent(positions), + ) + + +# --------------------------------------------------------------------------- +# Team world: a coach's own saved formation setups (doc 06 section 3.2). +# Reads open to both roles (roster.py precedent: the roster itself is +# player-viewable; only the analysis layer is coach-only); create/update +# are coach-only. +# --------------------------------------------------------------------------- + + +def _slot_to_out(slot: TeamFormationSlot, players: dict[int, Player], archetypes: dict[str, PositionArchetype]) -> TeamFormationSlotOut: + player = players.get(slot.player_id) if slot.player_id is not None else None + archetype = archetypes.get(slot.archetype_code) if slot.archetype_code is not None else None + return TeamFormationSlotOut( + slot=slot.slot, + player_id=slot.player_id, + player_name=player.name if player is not None else None, + archetype_code=slot.archetype_code, + archetype_name=archetype.name if archetype is not None else None, + qualitative_edge=slot.qualitative_edge, + ) + + +def _team_formation_to_out(scope: TeamScope, db: Session, formation: TeamFormation) -> TeamFormationOut: + slots = ( + scope.query_via( + TeamFormationSlot, TeamFormation, TeamFormationSlot.team_formation_id == TeamFormation.id + ) + .filter(TeamFormationSlot.team_formation_id == formation.id) + .all() + ) + player_ids = [s.player_id for s in slots if s.player_id is not None] + players = {p.id: p for p in (scope.query(Player).filter(Player.id.in_(player_ids)).all() if player_ids else [])} + archetype_codes = [s.archetype_code for s in slots if s.archetype_code is not None] + archetypes = { + a.code: a + for a in ( + db.query(PositionArchetype).filter(PositionArchetype.code.in_(archetype_codes)).all() + if archetype_codes + else [] + ) + } + return TeamFormationOut( + id=formation.id, + name=formation.name, + base_formation_code=formation.base_formation_code, + active_phase_variant=formation.active_phase_variant, + opponent_formation_code=formation.opponent_formation_code, + opponent_phase_variant=formation.opponent_phase_variant, + created_by_user_id=formation.created_by_user_id, + created_at=formation.created_at, + slots=[_slot_to_out(s, players, archetypes) for s in slots], + ) + + +def _validate_write(db: Session, scope: TeamScope, payload: TeamFormationWriteRequest) -> None: + if db.get(Formation, payload.base_formation_code) is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Unknown base_formation_code", + ) + if payload.opponent_formation_code is not None and db.get(Formation, payload.opponent_formation_code) is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Unknown opponent_formation_code", + ) + seen_slots: set[str] = set() + for slot_in in payload.slots: + if slot_in.slot in seen_slots: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Duplicate slot in request: {slot_in.slot}", + ) + seen_slots.add(slot_in.slot) + if slot_in.player_id is not None and scope.get(Player, slot_in.player_id) is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Unknown player_id for slot {slot_in.slot}", + ) + if slot_in.archetype_code is not None and db.get(PositionArchetype, slot_in.archetype_code) is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Unknown archetype_code for slot {slot_in.slot}", + ) + + +@router.get("/team-formations", response_model=list[TeamFormationOut]) +def list_team_formations( + ctx: CurrentMembership = Depends(get_current_membership), + scope: TeamScope = Depends(get_team_scope), + db: Session = Depends(get_db), +) -> list[TeamFormationOut]: + formations = scope.query(TeamFormation).order_by(TeamFormation.created_at.desc()).all() + return [_team_formation_to_out(scope, db, f) for f in formations] + + +@router.get("/team-formations/{team_formation_id}", response_model=TeamFormationOut) +def get_team_formation( + team_formation_id: int, + ctx: CurrentMembership = Depends(get_current_membership), + scope: TeamScope = Depends(get_team_scope), + db: Session = Depends(get_db), +) -> TeamFormationOut: + formation = scope.get(TeamFormation, team_formation_id) + if formation is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Team formation not found") + return _team_formation_to_out(scope, db, formation) + + +@router.post("/team-formations", response_model=TeamFormationOut, status_code=status.HTTP_201_CREATED) +def create_team_formation( + payload: TeamFormationWriteRequest, + ctx: CurrentMembership = Depends(require_role_on_team("coach")), + scope: TeamScope = Depends(get_team_scope), + db: Session = Depends(get_db), +) -> TeamFormationOut: + _validate_write(db, scope, payload) + + formation = TeamFormation( + name=payload.name, + base_formation_code=payload.base_formation_code, + active_phase_variant=payload.active_phase_variant, + opponent_formation_code=payload.opponent_formation_code, + opponent_phase_variant=payload.opponent_phase_variant, + created_by_user_id=ctx.user.id, + ) + scope.add(formation) + scope.flush() # assigns formation.id for the slot rows below + + # TeamFormationSlot has no team_id of its own (doc 06 section 3.2), so + # it is written through the plain db session once its PARENT row has + # been stamped by scope.add() above (app/scoped.py add() docstring). + for slot_in in payload.slots: + db.add( + TeamFormationSlot( + team_formation_id=formation.id, + slot=slot_in.slot, + player_id=slot_in.player_id, + archetype_code=slot_in.archetype_code, + qualitative_edge=slot_in.qualitative_edge, + ) + ) + scope.commit() + scope.refresh(formation) + return _team_formation_to_out(scope, db, formation) + + +@router.put("/team-formations/{team_formation_id}", response_model=TeamFormationOut) +def update_team_formation( + team_formation_id: int, + payload: TeamFormationWriteRequest, + ctx: CurrentMembership = Depends(require_role_on_team("coach")), + scope: TeamScope = Depends(get_team_scope), + db: Session = Depends(get_db), +) -> TeamFormationOut: + formation = scope.get(TeamFormation, team_formation_id) + if formation is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Team formation not found") + _validate_write(db, scope, payload) + + formation.name = payload.name + formation.base_formation_code = payload.base_formation_code + formation.active_phase_variant = payload.active_phase_variant + formation.opponent_formation_code = payload.opponent_formation_code + formation.opponent_phase_variant = payload.opponent_phase_variant + + # Full replace of the slot set (module docstring / schema docstring): + # the personnel panel saves all eleven slots as one unit, not a + # field-by-field patch, so this deletes and recreates rather than + # diffing the existing rows. + db.query(TeamFormationSlot).filter( + TeamFormationSlot.team_formation_id == formation.id + ).delete() + for slot_in in payload.slots: + db.add( + TeamFormationSlot( + team_formation_id=formation.id, + slot=slot_in.slot, + player_id=slot_in.player_id, + archetype_code=slot_in.archetype_code, + qualitative_edge=slot_in.qualitative_edge, + ) + ) + scope.commit() + scope.refresh(formation) + return _team_formation_to_out(scope, db, formation) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index f88dabf..1fcfe89 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -460,10 +460,24 @@ class SuggestionOut(BaseModel): class FormationPositionOut(BaseModel): """One slot from Formation.positions_json (doc 03 section 5): a landscape model coordinate plus the position_code the keystone lookup - and the board's on-token labels both key off of.""" + and the board's on-token labels both key off of. + + `slot_family` (doc 06 section 2.6, T-110 seed) is additive as of + T-107: it is the key the personnel panel needs to call + GET /api/archetypes and GET /api/archetypes/suggest for this slot. + T-110 seeded it on seeds/formations.json (validator-enforced there) + but never on seeds/formation_phases.json, whose positions_json rows + carry only slot/position_code/x/y (scripts/validate_seeds.py does not + require it there). Optional rather than required so + GET /formations/{code}/phases, which parses FormationPhaseOut.positions + straight off that column via validation_alias, keeps validating: it + comes back None on a phase variant, and app/routers/formations.py + below is the only place that ever populates it (from the base + formation, where it is always present).""" slot: str position_code: str + slot_family: str | None = None x: float y: float @@ -480,13 +494,36 @@ class FormationKeystoneOut(BaseModel): class RondoZoneOut(BaseModel): """One rondo_zones row (Bible 3G.2): a tappable zone on the Rondo Map, - naming which rondo lives there and which library patterns it trains.""" + naming which rondo lives there and which library patterns it trains. + + The last three fields are doc 06 section 2.3's. T-101 added them to the + model (migration 0006) and T-103 seeded them, but they were never put on + the wire, so the Formations page could neither draw the counterpress + ring (it needs zone_kind and radius to know the zone is a ball-relative + circle rather than its seeded bounding polygon) nor show a + no-opposition fallback chip without splitting the ratio back out of + rondo_name. T-112 closes that. + """ zone_key: str rondo_name: str teaches: str polygon: list[ModelPoint] trains_pattern_codes: list[str] + # The label shown when NO opposition is placed, e.g. '4v2'. Nullable on + # the model (T-101 added the column before T-103 had content for every + # row), so nullable here: a client that gets null renders no fallback + # chip rather than an empty one. With opposition on the board this field + # is not read at all, because the ratio is computed from the two shapes + # actually on the pitch. + canonical_rondo: str | None = None + # polygon | ball_relative_circle. Defaulted rather than required so a + # row written before migration 0006 still serializes as the polygon it + # is. + zone_kind: str = "polygon" + # Model units, and only meaningful when zone_kind is + # ball_relative_circle. Null on every polygon zone. + radius: int | None = None class FormationOut(BaseModel): @@ -632,3 +669,256 @@ class CoachSessionOut(SessionOut): receipts: list[SessionReceiptOut] viewed_count: int recipient_count: int + + +# --------------------------------------------------------------------------- +# Tactics Lab (Epic T-100, doc 06 section 3; T-108). Two worlds, same split +# as the rest of this file: +# - library world (formation_phases, rotation_systems, position_archetypes, +# formation_matchups): read-only, seeded, visible to both roles, same +# from_attributes + validation_alias convention as IdentityOut / +# LibraryItemOut above (their *_json columns become plain-named fields). +# - team world (team_formations, team_formation_slots): doc 06 section 3.2, +# the TeamFormation*In/Out split follows PlayerWriteRequest/PlayerOut: +# no team_id/created_by_user_id field in any request body (CLAUDE.md +# rule 4), both stamped server-side from the caller's own scope/ +# membership (app/routers/tactics.py). +# --------------------------------------------------------------------------- + + +class FormationPhaseOut(BaseModel): + """One formation_phases row (doc 06 section 3.1): a named shape a base + formation morphs into in one phase of play. `positions` reuses + FormationPositionOut since positions_json is the same {slot, + position_code, x, y} shape as Formation.positions_json, "same slot ids + as the base formation, all eleven" (doc 06 section 3.1).""" + + model_config = ConfigDict(from_attributes=True) + + formation_code: str + variant_code: str + phase: Literal["in_possession", "out_of_possession", "rest_defence", "transition"] + name: str + shape_label: str + blurb: str + positions: list[FormationPositionOut] = Field(validation_alias="positions_json") + trigger: str + rest_shape: str | None + reference_code: str | None + uses_rotations: list[str] + + +class FormationMatchupOut(BaseModel): + """One formation_matchups row (doc 06 section 3.1): "the how the ball + finds it" card for one unordered pair of formations. Read exactly as + authored (ours_code/theirs_code/edges/route are the seeded row's own + fields, not swapped for query order); see + GET /api/formations/matchup for how a query's ours/theirs resolve to + this row regardless of which order they were asked in.""" + + model_config = ConfigDict(from_attributes=True) + + ours_code: str + theirs_code: str + our_edges: list[str] = Field(validation_alias="our_edges_json") + their_edges: list[str] = Field(validation_alias="their_edges_json") + route: str + route_kind: Literal["through", "around", "over"] + + +class FormationMatchupResponse(BaseModel): + """GET /api/formations/matchup always returns 200 (BoardStateOut's + null-not-404 pattern): `matchup` is null when the two (valid) formation + codes have no seeded card yet, which doc 06 section 2's engine + discussion treats as a normal, expected state ("say plainly that this + pair has no coached read yet. Do not invent one."), not an error.""" + + ours_code: str + theirs_code: str + matchup: FormationMatchupOut | None + + +class RotationSystemOut(BaseModel): + """One rotation_systems row (doc 06 section 3.1).""" + + model_config = ConfigDict(from_attributes=True) + + code: str + name: str + family: Literal["first_line", "pivot", "wide", "front_line"] + applies_to_formations: list[str] + produces_shape: str + trigger: str + what_moves: list = Field(validation_alias="what_moves_json") + coaching_points: list[str] = Field(validation_alias="coaching_points_json") + risk: str + requires_profile: dict | None = Field(default=None, validation_alias="requires_profile_json") + animation_spec: AnimationSpec | None = Field(default=None, validation_alias="animation_spec_json") + exemplar_note: str | None + + +class PositionArchetypeOut(BaseModel): + """One position_archetypes row (doc 06 section 3.1). `slot_family` + reuses the position_codes vocabulary (GK, CB, FB, WB, DM, CM, AM, W, + ST, SS; app/models/roster.py PositionCode), the ten families T-102 + seeds one or more archetypes for.""" + + model_config = ConfigDict(from_attributes=True) + + code: str + slot_family: str + name: str + definition: str + key_attribute_keys: list[AttributeKey] + foot_hint: Literal["same_side", "opposite_side", "either"] | None + awr_default: WorkRate + dwr_default: WorkRate + duties: list[str] = Field(validation_alias="duties_json") + enables_pattern_codes: list[str] + enables_rotation_codes: list[str] + needs_around_it: str + exemplar_note: str | None + + +class ArchetypeSuggestionOut(BaseModel): + """One ranked candidate (doc 06 section 5.3): "Show the top three with + a one-line why for each. The why must cite the actual reason..., not a + score." `why` is built server-side from the player's actual attribute + values, footedness, and work rates, never a numeric score + (app/routers/tactics.py _build_why).""" + + archetype_code: str + archetype_name: str + slot_family: str + why: str + + +class ArchetypeSuggestResponse(BaseModel): + """GET /api/archetypes/suggest, coach-only. Empty roster is a first + class state (doc 06 section 5.3: "the panel still works with + archetypes alone and no players assigned"): when no player_id is + given, `suggestions` still lists candidate archetypes for the slot + family, just without an attribute-fit why.""" + + slot_family: str + player_id: int | None + suggestions: list[ArchetypeSuggestionOut] + + +class TeamFormationSlotIn(BaseModel): + """One slot entry inside a team formation write request (doc 06 + section 3.2). No team_formation_id (comes from the parent row this is + nested under, stamped server-side, CLAUDE.md rule 4).""" + + model_config = ConfigDict(extra="forbid") + + slot: str = Field(min_length=1, max_length=30) + player_id: int | None = None + archetype_code: str | None = None + qualitative_edge: bool = False + + +class TeamFormationWriteRequest(BaseModel): + """Shared body shape for POST (create) and PUT (full update) of a + saved team formation, same "no team_id/author field, full slot-set + replace" convention as PlayerWriteRequest / SessionCreateRequest. + `slots` replaces the whole slot set on every write (doc 06 section + 5.3's personnel panel saves all eleven slots as one unit, not a + field-by-field API).""" + + model_config = ConfigDict(extra="forbid") + + name: str = Field(min_length=1, max_length=120) + base_formation_code: str + active_phase_variant: str = Field(min_length=1, max_length=30) + opponent_formation_code: str | None = None + opponent_phase_variant: str | None = None + slots: list[TeamFormationSlotIn] = Field(default_factory=list) + + +class TeamFormationSlotOut(BaseModel): + slot: str + player_id: int | None + # Resolved server-side (PlayerOut.role_name / SavedPatternOut.author_label + # precedent) so the frontend never re-derives it from a second round trip. + player_name: str | None + archetype_code: str | None + archetype_name: str | None + qualitative_edge: bool + + +class TeamFormationOut(BaseModel): + id: int + name: str + base_formation_code: str + active_phase_variant: str + opponent_formation_code: str | None + opponent_phase_variant: str | None + created_by_user_id: int + created_at: datetime + slots: list[TeamFormationSlotOut] + + +# --------------------------------------------------------------------------- +# Unit balance evaluation (T-110, doc 06 sections 2.6 / 3.1 / 5.3). +# Coach-only, exactly like FitWarningOut above: doc 06 section 5.3 says +# unit balance is "coach-only, both in the UI and at the API", so no +# player-role payload has a field for any of these (CLAUDE.md rule 5). +# --------------------------------------------------------------------------- + + +class UnitBalanceSlotIn(BaseModel): + """One slot's current archetype pick. `archetype_code` is nullable + because doc 06 section 5.3 makes an unassigned slot a first-class + state, not an error.""" + + model_config = ConfigDict(extra="forbid") + + slot: str = Field(min_length=1, max_length=30) + archetype_code: str | None = None + + +class UnitBalanceRequest(BaseModel): + """Body of the live balance evaluation. Carries only the archetype + picks: no team_id (CLAUDE.md rule 4) and no player ids, because unit + balance is evaluated on archetypes alone, which is what lets the panel + work against an empty roster.""" + + model_config = ConfigDict(extra="forbid") + + slots: list[UnitBalanceSlotIn] = Field(default_factory=list) + + +class UnitBalanceNoteOut(BaseModel): + """One fired unit_balance_rules row. Same shape as FitWarningOut + carries a fired role_clashes row (doc 06 section 3.1: "reuse its + evaluation shape so the two engines read alike"). `message` is the + seeded warning_copy verbatim, never composed here.""" + + code: str + unit: str + flank: Flank | None + severity: Literal["note", "warning"] + message: str + slots: list[str] + + +class UnitBalanceUnitOut(BaseModel): + unit: str + # Set only for wide_unit, which occurs once per touchline. + flank: Flank | None + slots: list[str] + assigned_slots: list[str] + is_complete: bool + notes: list[UnitBalanceNoteOut] + + +class UnitBalanceResponse(BaseModel): + """`units_not_evaluated` is part of the contract, not debug output: a + unit the formation does not contain must be visibly absent rather than + silently scored empty, so the panel can say why a 4-3-3 shows no + double-pivot section.""" + + formation_code: str + units: list[UnitBalanceUnitOut] + units_not_evaluated: list[str] diff --git a/backend/app/units.py b/backend/app/units.py new file mode 100644 index 0000000..a1bcec3 --- /dev/null +++ b/backend/app/units.py @@ -0,0 +1,401 @@ +"""Slot-to-unit crosswalk and the unit balance evaluator (T-110, doc 06 +sections 2.6, 3.1 and 5.3). + +Why this module exists. Doc 06 section 0 calls unit-balance warnings the +core mechanic of the Tactics Lab, but doc 06 never states how a +formation's eleven named slots map onto the seven `unit_balance_rules` +units. T-108 hit that gap building /archetypes/suggest and stopped rather +than invent a mapping: `archetype_combinations.slots_json` is a +[{slot_family, archetype_code}] combination TEMPLATE, not per-formation +slot membership. + +The founder's approved resolution (2026-08-07) is two-part: + + 1. `slot_family` is SEEDED per slot, on seeds/formations.json, because + `position_code` is too coarse to carry the football. It cannot tell a + back three's outer defender (cb_wide) from its middle one + (cb_central), nor a six from an eight when both are CM. Seeding it + explicitly is what makes a 3-4-3's wide player correctly a wing back + and a 4-3-3's correctly a fullback. + 2. The slot_family-to-unit crosswalk is a FIXED MAP IN CODE, below. It + is vocabulary, not content: it changes only when doc 06's unit or + slot-family vocabularies change, which is a spec change rather than a + seed change (the same reasoning doc 06 section 3.1 gives for keeping + the duty vocabulary closed). + +The evaluation shape deliberately mirrors the existing role_clashes +mechanic in app/routers/roster.py (`_compute_fit_warnings`): iterate the +seeded rows, fire per flank where a flank applies, and carry the SEEDED +`warning_copy` through as the message rather than composing copy in code. +Doc 06 section 3.1 asks for exactly that ("reuse its evaluation shape so +the two engines read alike"), and Brief section 7 says content is data, +not code. + +Three rules govern what gets evaluated, and all three exist to protect +trust in the warnings: + + * A unit the formation does not contain is never evaluated. Firing "this + double pivot has no tempo setter" at a 4-3-3, which has no double + pivot, would be worse than saying nothing and would discredit every + other warning on the page. + * `requires_duty` rules only run on a unit whose slots ALL carry an + archetype. Half-assigned is not imbalanced, it is unfinished, and a + coach planning a shape at 11pm should not be shouted at mid-click. + * `max_duty` and `max_same_archetype` rules run on whatever is assigned, + because an excess that already exists cannot be undone by assigning + more slots. They are monotone, so firing early is still true. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field +from typing import Literal, Protocol + +# --------------------------------------------------------------------------- +# Vocabularies (doc 06 sections 2.6 and 3.1) +# --------------------------------------------------------------------------- + +SlotFamily = Literal[ + "gk", "cb_central", "cb_wide", "fb", "wb", + "six", "eight", "ten", "wide_forward", "nine", +] + +Unit = Literal[ + "midfield_three", "double_pivot", "front_three", "strike_pair", + "back_line", "wide_unit", "box_midfield", +] + +SLOT_FAMILIES: tuple[str, ...] = ( + "gk", "cb_central", "cb_wide", "fb", "wb", + "six", "eight", "ten", "wide_forward", "nine", +) + +# Doc 06 section 3.1's own enumeration order, not alphabetical, matching the +# FORMATION_ORDER / PHASE_ORDER convention in app/routers/formations.py and +# app/routers/tactics.py so a response reads in the document's order. +UNITS: tuple[str, ...] = ( + "midfield_three", "double_pivot", "front_three", "strike_pair", + "back_line", "wide_unit", "box_midfield", +) + +# THE CROSSWALK. Which units a slot family can participate in. A family may +# belong to more than one unit at once (a fullback is in the back line AND +# in his flank's wide unit, and doc 06 section 2.6 evaluates both), and a +# family may name two ALTERNATIVES that a formation then resolves between +# (a six is in a midfield three or in a double pivot, never both). Which of +# the two applies is decided per formation by `unit_membership` below. +# +# Three families map to nothing, each for a stated reason rather than an +# oversight: +# gk Doc 06's units are all outfield. `bl_needs_a_coverer`'s seeded copy +# ("check the goalkeeper is picked for it") treats the keeper as +# something the back line is evaluated AGAINST, not a member of it. +# ten Doc 06 gives the ten no unit. The only candidate in the vocabulary +# is box_midfield, which section 2.6 leaves without a framework, so +# placing him there would be inventing one. +# box_midfield is reachable from no family, for the same reason: T-102 +# seeded no combinations and no rules for it because doc 06 section +# 2.6 gives it none. It stays in the vocabulary and out of the map. +SLOT_FAMILY_UNITS: dict[str, tuple[str, ...]] = { + "gk": (), + "cb_central": ("back_line",), + "cb_wide": ("back_line",), + "fb": ("back_line", "wide_unit"), + "wb": ("back_line", "wide_unit"), + "six": ("midfield_three", "double_pivot"), + "eight": ("midfield_three", "double_pivot"), + "ten": (), + "wide_forward": ("front_three", "wide_unit"), + "nine": ("front_three", "strike_pair"), +} + +# Cardinalities the resolvers below check against, named rather than +# inlined so the football reason for each is readable in one place. +_CENTRAL_MIDFIELD_FAMILIES = ("six", "eight") +_BACK_LINE_FAMILIES = ("cb_central", "cb_wide", "fb", "wb") +_WIDE_UNIT_FAMILIES = ("fb", "wb", "wide_forward") +_MIDFIELD_THREE_SIZE = 3 +_DOUBLE_PIVOT_SIZE = 2 +_FRONT_THREE_SIZE = 3 +_FRONT_THREE_NINES = 1 +_STRIKE_PAIR_SIZE = 2 +_MIN_BACK_LINE_SIZE = 3 +_WIDE_UNIT_SIZE = 2 + +Flank = Literal["left", "right", "center"] + + +def flank_of(y: float) -> Flank: + """Landscape model coords, y running 0 to 100 top to bottom (CLAUDE.md + rule 8), so the left half of the pitch is the low half of y. Derived + from the coordinate rather than parsed out of the slot id suffix: the + coordinate is the stored truth and a slot id is a label.""" + if y < 50: + return "left" + if y > 50: + return "right" + return "center" + + +@dataclass(frozen=True) +class SlotRef: + """One of a formation's eleven slots, flattened out of + formations.positions_json.""" + + slot: str + slot_family: str + flank: Flank + + +@dataclass(frozen=True) +class UnitInstance: + """One evaluable occurrence of a unit within one formation. + + `flank` is set only for wide_unit, which occurs twice per formation + (once per touchline) because doc 06 section 2.6 defines it as + "fullback plus wide forward" on one side and the seeded warning copy + speaks about "this flank". Every other unit occurs at most once and + carries flank=None.""" + + unit: str + flank: Flank | None + slots: tuple[str, ...] + + +def _families(slots: list[SlotRef], families: tuple[str, ...]) -> list[SlotRef]: + return [s for s in slots if s.slot_family in families] + + +def slot_refs(positions: list[dict]) -> list[SlotRef]: + """Flatten formations.positions_json (or a formation_phases row's, which + carries the same slot set by validator rule) into SlotRefs. A position + without a slot_family cannot happen in seeded data: the validator + requires the field on every formation position. Anything else is + skipped rather than guessed at.""" + refs: list[SlotRef] = [] + for position in positions: + family = position.get("slot_family") + slot = position.get("slot") + if not isinstance(family, str) or not isinstance(slot, str): + continue + if family not in SLOT_FAMILY_UNITS: + continue + refs.append(SlotRef(slot=slot, slot_family=family, flank=flank_of(position.get("y", 50)))) + return refs + + +def unit_membership(positions: list[dict]) -> list[UnitInstance]: + """The actual slot membership of every unit this formation contains. + + A unit the formation does not contain is simply absent from the result, + never present-and-empty, because an empty unit would trip every + `requires_duty` rule attached to it and shout about a double pivot at a + 4-3-3 that has none. + + The resolutions, and the football behind each: + + midfield_three vs double_pivot. Both draw on the same {six, eight} + slots, so the count decides: three central midfielders is a midfield + three, two is a double pivot. A 4-3-3 (six plus two eights) and a + 3-5-2 (six plus two eights) resolve to a trio; a 4-2-3-1, a flat + 4-4-2 and a 3-4-3 resolve to a pivot pair. + + front_three vs strike_pair. Counting all of {wide_forward, nine} + together would call a flat 4-4-2's two wide midfielders plus two + strikers a four, so the two resolve on different sets: a front three + is three of {wide_forward, nine} with exactly one nine (the two + wingers and the centre forward), and a strike pair is exactly two + nines. A 4-4-2 therefore has a strike pair and no front three, and a + 5-4-1's lone striker is neither, which is correct: one forward is + not a unit. + + back_line takes every {cb_central, cb_wide, fb, wb} slot, so a back + four is a four and a 3-5-2 or 5-4-1 back line is a five. Wing backs + count, per the approved crosswalk: the rules ask who steps and who + covers, and in a back five the wing backs do both. + + wide_unit is per flank, two instances, each needing exactly two + players on that side from {fb, wb, wide_forward}. Doc 06 section 2.6 + defines it as a PAIR ("fullback plus wide forward"), and the seeded + copy says "neither player on this flank", so a flank holding only a + wing back (3-5-2, 5-4-1) is not a wide unit and is not evaluated. + """ + slots = slot_refs(positions) + instances: list[UnitInstance] = [] + + central = _families(slots, _CENTRAL_MIDFIELD_FAMILIES) + if len(central) == _MIDFIELD_THREE_SIZE: + instances.append(UnitInstance("midfield_three", None, tuple(s.slot for s in central))) + elif len(central) == _DOUBLE_PIVOT_SIZE: + instances.append(UnitInstance("double_pivot", None, tuple(s.slot for s in central))) + + front = _families(slots, ("wide_forward", "nine")) + nines = _families(slots, ("nine",)) + if len(front) == _FRONT_THREE_SIZE and len(nines) == _FRONT_THREE_NINES: + instances.append(UnitInstance("front_three", None, tuple(s.slot for s in front))) + if len(nines) == _STRIKE_PAIR_SIZE: + instances.append(UnitInstance("strike_pair", None, tuple(s.slot for s in nines))) + + back = _families(slots, _BACK_LINE_FAMILIES) + if len(back) >= _MIN_BACK_LINE_SIZE: + instances.append(UnitInstance("back_line", None, tuple(s.slot for s in back))) + + for flank in ("left", "right"): + wide = [s for s in _families(slots, _WIDE_UNIT_FAMILIES) if s.flank == flank] + if len(wide) == _WIDE_UNIT_SIZE: + instances.append( + UnitInstance("wide_unit", flank, tuple(s.slot for s in wide)) # type: ignore[arg-type] + ) + + instances.sort(key=lambda i: (UNITS.index(i.unit), i.flank or "")) + return instances + + +def units_present(positions: list[dict]) -> list[str]: + seen: list[str] = [] + for instance in unit_membership(positions): + if instance.unit not in seen: + seen.append(instance.unit) + return seen + + +def units_absent(positions: list[dict]) -> list[str]: + present = set(units_present(positions)) + return [unit for unit in UNITS if unit not in present] + + +# --------------------------------------------------------------------------- +# Evaluation +# --------------------------------------------------------------------------- + + +class ArchetypeLike(Protocol): + """Structural view of app.models.PositionArchetype, so the evaluator + stays a pure function over data and its tests do not need a database.""" + + code: str + name: str + duties_json: list + + +class BalanceRuleLike(Protocol): + """Structural view of app.models.UnitBalanceRule.""" + + code: str + unit: str + rule_kind: str + duty: str | None + min_count: int | None + max_count: int | None + warning_copy: str + severity: str + + +@dataclass(frozen=True) +class BalanceNote: + """One fired unit_balance_rules row. The same shape as roster.py's + FitWarningOut carries a fired role_clashes row: the seeded code, the + flank where a flank applies, the SEEDED copy as the message, and the + subjects it fired on.""" + + code: str + unit: str + flank: Flank | None + severity: str + message: str + slots: tuple[str, ...] + + +@dataclass(frozen=True) +class UnitEvaluation: + unit: str + flank: Flank | None + slots: tuple[str, ...] + assigned_slots: tuple[str, ...] + is_complete: bool + notes: list[BalanceNote] = field(default_factory=list) + + +def _fires( + rule: BalanceRuleLike, + assigned: Sequence[ArchetypeLike], + is_complete: bool, +) -> bool: + if rule.rule_kind == "requires_duty": + # Only on a finished unit: see the module docstring. An unassigned + # slot might still be the metronome the trio is missing. + if not is_complete or rule.duty is None: + return False + holders = sum(1 for a in assigned if rule.duty in (a.duties_json or [])) + return holders < (rule.min_count or 0) + if rule.rule_kind == "max_duty": + if rule.duty is None or rule.max_count is None: + return False + holders = sum(1 for a in assigned if rule.duty in (a.duties_json or [])) + return holders > rule.max_count + if rule.rule_kind == "max_same_archetype": + if rule.max_count is None: + return False + counts: dict[str, int] = {} + for a in assigned: + counts[a.code] = counts.get(a.code, 0) + 1 + return any(n > rule.max_count for n in counts.values()) + return False + + +def evaluate_unit_balance( + positions: list[dict], + assignments: Mapping[str, str | None], + # Mapping/Sequence rather than dict/list so an ORM row type (a + # PositionArchetype, a UnitBalanceRule) satisfies the protocol: dict and + # list are invariant in their value type and would reject it. + archetypes: Mapping[str, ArchetypeLike], + rules: Sequence[BalanceRuleLike], +) -> list[UnitEvaluation]: + """Evaluate every unit this formation contains against the seeded + unit_balance_rules rows. + + `assignments` maps slot id to archetype code, and a slot may be missing + from it or map to None. That is a first-class state, not an error (doc + 06 section 5.3: "the panel still works with archetypes alone and no + players assigned"), so a unit with nothing assigned comes back listed, + complete=False, and silent. + """ + rules_by_unit: dict[str, list[BalanceRuleLike]] = {} + for rule in rules: + rules_by_unit.setdefault(rule.unit, []).append(rule) + + evaluations: list[UnitEvaluation] = [] + for instance in unit_membership(positions): + assigned_slots = tuple( + slot for slot in instance.slots if assignments.get(slot) in archetypes + ) + assigned = [archetypes[assignments[slot]] for slot in assigned_slots] # type: ignore[index] + is_complete = len(assigned_slots) == len(instance.slots) + + notes: list[BalanceNote] = [] + if assigned: + for rule in rules_by_unit.get(instance.unit, []): + if _fires(rule, assigned, is_complete): + notes.append( + BalanceNote( + code=rule.code, + unit=instance.unit, + flank=instance.flank, + severity=rule.severity, + message=rule.warning_copy, + slots=assigned_slots, + ) + ) + evaluations.append( + UnitEvaluation( + unit=instance.unit, + flank=instance.flank, + slots=instance.slots, + assigned_slots=assigned_slots, + is_complete=is_complete, + notes=notes, + ) + ) + return evaluations diff --git a/backend/migrations/versions/0007_delete_orphan_counterpress_zone.py b/backend/migrations/versions/0007_delete_orphan_counterpress_zone.py new file mode 100644 index 0000000..506ea27 --- /dev/null +++ b/backend/migrations/versions/0007_delete_orphan_counterpress_zone.py @@ -0,0 +1,84 @@ +"""Delete the orphan rondo_zones row left behind by T-103's rename (doc 06 +section 2.3, T-111). + +T-103 renamed the rondo_zones zone_key `counterpress` to +`counterpress_ring` and changed its zone_kind from a fixed `polygon` to a +ball-relative `circle` of radius 18, rewriting seeds/rondo_zones.json to +match: the seed file now carries `counterpress_ring` for all six +formations and no `counterpress` row at all. + +scripts/seed.py is upsert-only by natural key (doc 03 section 8.4) and +never deletes rows that a newer seed file no longer lists. That is +deliberate and correct in general, but it means any database that was +already seeded before T-103 landed still carries the old `('433', +'counterpress')` row: nothing in an upsert-only seeder ever removes it. +Left alone, a persistent-disk deploy upgraded straight from before T-103 +would render seven rondo zones on the 4-3-3 (six current ones plus this +stale seventh) instead of six. + +A fresh database seeded after T-103 never had this row in the first +place, which is why this is specifically an upgrade-path bug and not +something a from-zero build or CI would ever catch. + +Written generically over zone_key = 'counterpress' across every +formation_code (not hardcoded to '433'), so it is correct regardless of +which formations a given database happens to hold a stale row for. + +Revision ID: 0007 +Revises: 0006 +Create Date: 2026-08-07 00:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0007" +down_revision: Union[str, None] = "0006" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +# Lightweight Core table, only the column this data migration filters on, +# typed so the delete goes through plain Core rather than raw textual SQL +# (same style as 0006's _rondo_zones construct). +_rondo_zones = sa.table( + "rondo_zones", + sa.column("zone_key", sa.String), +) + + +def upgrade() -> None: + # Matches zero rows on any database seeded on or after T-103 (the + # normal, fresh-install case), so this is a no-op there: idempotent + # in effect, not just safe to re-run. + op.execute(sa.delete(_rondo_zones).where(_rondo_zones.c.zone_key == "counterpress")) + + +def downgrade() -> None: + # Deliberately a no-op, not a reinsertion. + # + # 0006's downgrade could rebuild flank_corridor from flank_corridor_ + # right because that row's polygon was still sitting in the same + # table, untouched, right up until the moment of deletion: the split + # was a pure copy-then-delete, so the source data for a merge-back + # was always present. + # + # This migration has no such source to work from. The deleted + # counterpress row's polygon was a value in a database row, never + # captured anywhere in the schema or in this migration itself, and + # the counterpress_ring rows that replace it are not a derivation of + # that polygon: they are an unrelated shape (a ball-relative circle + # of radius 18 vs. the old fixed polygon), seeded independently by + # scripts/seed.py from seeds/rondo_zones.json. There is nothing left + # in the post-upgrade schema state that the original stale polygon + # could be reconstructed from. + # + # A downgrade that reinserted some hardcoded polygon here would not + # be restoring what upgrade() deleted; it would be fabricating a + # value and asserting it was the prior state. That is worse than + # doing nothing, so downgrade() intentionally leaves the counterpress + # row deleted. + pass diff --git a/backend/tests/test_formations_routes.py b/backend/tests/test_formations_routes.py index 9ff4d79..6ee1d8b 100644 --- a/backend/tests/test_formations_routes.py +++ b/backend/tests/test_formations_routes.py @@ -118,18 +118,83 @@ def test_rondo_zones_show_their_rondo_and_linked_patterns(client: TestClient) -> "flank_corridor_left", "flank_corridor_right", "last_line", - "counterpress", + # T-103: doc 06 section 2.3 renames the zone and redefines it as a + # ball-relative circle rather than a fixed polygon. + "counterpress_ring", } midfield = zones["midfield_box"] assert midfield["rondo_name"] == "5v3 (the midfield box)" - assert midfield["trains_pattern_codes"] == ["B8", "A5"] + # T-103 seeded doc 06 section 2.3's own "trains" list for this zone. + assert midfield["trains_pattern_codes"] == ["A5", "B8"] assert len(midfield["polygon"]) == 4 assert all({"x", "y"} <= set(pt) for pt in midfield["polygon"]) - # Only 433 carries a seeded rondo map today (seeds/rondo_zones.json note). + # T-103 seeded the rondo map on all six formations, so every formation + # now carries the full set of six zones rather than only the 4-3-3. f442 = formations["442"] - assert f442["rondo_zones"] == [] + assert {z["zone_key"] for z in f442["rondo_zones"]} == set(zones) + + +def test_the_counterpress_ring_arrives_as_a_ball_relative_circle(client: TestClient) -> None: + """doc 06 section 2.3, T-112. The ring is a circle of radius 18 model + units that follows the ball, not the polygon seeded next to it. Without + zone_kind and radius on the wire the page cannot tell the two apart, so + it either draws the polygon (an eleven-a-side count dressed up as a + 4v2) or draws nothing, which is what shipped between T-106 and T-112.""" + coach = _coach_with_team() + formations = {f["code"]: f for f in coach.get("/api/formations").json()} + + for code, formation in formations.items(): + zones = {z["zone_key"]: z for z in formation["rondo_zones"]} + ring = zones["counterpress_ring"] + assert ring["zone_kind"] == "ball_relative_circle", code + assert ring["radius"] == 18, code + # The bounding polygon still ships. It is what the seed carries and + # what a later surface may use to bound where the ring can sit; the + # renderer's contract is that it never DRAWS it for this zone. + assert len(ring["polygon"]) >= 3, code + + for key, zone in zones.items(): + if key == "counterpress_ring": + continue + assert zone["zone_kind"] == "polygon", f"{code}.{key}" + assert zone["radius"] is None, f"{code}.{key}" + + +def test_every_rondo_zone_carries_its_no_opposition_fallback_label(client: TestClient) -> None: + """canonical_rondo is the chip shown when no opposition is placed + (doc 06 section 5.1). It is a SEEDED label and never a computed count, + which is why the page renders it muted; the point of putting it on the + wire is that the page stops splitting the ratio back out of + rondo_name.""" + coach = _coach_with_team() + formations = {f["code"]: f for f in coach.get("/api/formations").json()} + + for code, formation in formations.items(): + for zone in formation["rondo_zones"]: + assert zone["canonical_rondo"], f"{code}.{zone['zone_key']}" + + zones = {z["zone_key"]: z for z in formations["433"]["rondo_zones"]} + assert zones["midfield_box"]["canonical_rondo"] == "5v3" + assert zones["counterpress_ring"]["canonical_rondo"] == "4v4 plus 3" + + +def test_rondo_zones_arrive_in_the_map_order(client: TestClient) -> None: + """First-line build-up through to the counterpress moment (Bible 3G.2). + Asserted rather than assumed because the zone key changed in T-103 and + the router's ordering tuple did not follow it until T-112.""" + coach = _coach_with_team() + formations = {f["code"]: f for f in coach.get("/api/formations").json()} + for code, formation in formations.items(): + assert [z["zone_key"] for z in formation["rondo_zones"]] == [ + "first_line", + "midfield_box", + "flank_corridor_left", + "flank_corridor_right", + "last_line", + "counterpress_ring", + ], code def test_em_dash_never_appears_in_a_formations_response(client: TestClient) -> None: diff --git a/backend/tests/test_migrations.py b/backend/tests/test_migrations.py index 0d5baf8..8e6f54b 100644 --- a/backend/tests/test_migrations.py +++ b/backend/tests/test_migrations.py @@ -254,3 +254,153 @@ def test_flank_corridor_row_split_downgrades_back_to_one_row(fresh_db_url: str) assert json.loads(merged.trains_pattern_codes) == ["A1", "A2", "F1"] assert merged.source_ref == "bible:3G.2" assert merged.content_version == "1.0.0" + + +def _insert_post_0006_counterpress_row(conn) -> None: + """A rondo_zones row in the exact shape a database seeded between + 0006 landing and T-103's seed rewrite would still hold: the old + zone_key `counterpress` with its fixed polygon, using only the + columns the pre-T-103 seed file ever set for this row (zone_kind is + given explicitly here as 'polygon', the value scripts/seed.py's + upsert would have left in place via 0006's own server_default, + since the pre-T-103 seed file never mentioned zone_kind for this + row at all).""" + conn.execute( + text( + "INSERT INTO rondo_zones " + "(formation_code, zone_key, polygon_json, rondo_name, teaches, " + "trains_pattern_codes, source_ref, content_version, zone_kind) " + "VALUES (:formation_code, :zone_key, :polygon_json, :rondo_name, " + ":teaches, :trains_pattern_codes, :source_ref, :content_version, " + ":zone_kind)" + ), + { + "formation_code": "433", + "zone_key": "counterpress", + "polygon_json": json.dumps( + [{"x": 30, "y": 10}, {"x": 70, "y": 10}, {"x": 70, "y": 90}, {"x": 30, "y": 90}] + ), + "rondo_name": "4v4+3 (the counterpress moment)", + "teaches": ( + "The five-second swarm is the neutral-player transition game " + "played for real stakes, wherever the ball is lost." + ), + "trains_pattern_codes": json.dumps(["C1"]), + "source_ref": "bible:3G.2", + "content_version": "1.0.0", + "zone_kind": "polygon", + }, + ) + + +def _insert_counterpress_ring_row(conn, formation_code: str) -> None: + """A rondo_zones row in T-103's current shape, standing in for the + counterpress_ring row a re-run of the seeder has already written + alongside the stale orphan above (an upsert-only seeder adds the new + zone_key without ever touching the old one). Proves 0007 deletes + only the retired zone_key and leaves its replacement untouched.""" + conn.execute( + text( + "INSERT INTO rondo_zones " + "(formation_code, zone_key, polygon_json, rondo_name, teaches, " + "trains_pattern_codes, source_ref, content_version, canonical_rondo, " + "zone_kind, radius) " + "VALUES (:formation_code, :zone_key, :polygon_json, :rondo_name, " + ":teaches, :trains_pattern_codes, :source_ref, :content_version, " + ":canonical_rondo, :zone_kind, :radius)" + ), + { + "formation_code": formation_code, + "zone_key": "counterpress_ring", + "polygon_json": json.dumps( + [{"x": 50, "y": 0}, {"x": 100, "y": 0}, {"x": 100, "y": 100}, {"x": 50, "y": 100}] + ), + "rondo_name": "4v4+3 (the counterpress ring)", + "teaches": "Lose the ball in their half and the swarm is the pivot.", + "trains_pattern_codes": json.dumps(["C1"]), + "source_ref": "doc06:2.3", + "content_version": "1.1.0", + "canonical_rondo": "4v4 plus 3", + "zone_kind": "ball_relative_circle", + "radius": 18, + }, + ) + + +def test_orphan_counterpress_row_deleted_on_upgrade_to_0007(fresh_db_url: str) -> None: + """T-111 / Platform DoD: proves the exact upgrade path a persistent- + disk deploy seeded before T-103 would hit. Builds a DB up to 0006 + (the pre-0007 head, with T-101's rondo_zones columns already + present), inserts a stale `counterpress` row (the shape a pre-T-103 + seed run left behind) alongside a `counterpress_ring` row (the shape + a subsequent, post-T-103 seed run also wrote for the same + formation, since scripts/seed.py's upsert-only seeder adds the new + zone without ever removing the old one), then upgrades to head + (0007) and asserts: the orphan `counterpress` row is gone, and the + `counterpress_ring` row is completely untouched. That is what makes + the 4-3-3 render six rondo zones again instead of seven.""" + cfg = _alembic_config() + command.upgrade(cfg, "0006") + + engine = create_engine(fresh_db_url) + with engine.begin() as conn: + _insert_formation_433(conn) + _insert_post_0006_counterpress_row(conn) + _insert_counterpress_ring_row(conn, "433") + 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 "counterpress" not in rows + ring = rows["counterpress_ring"] + assert ring.rondo_name == "4v4+3 (the counterpress ring)" + assert ring.zone_kind == "ball_relative_circle" + assert ring.radius == 18 + assert ring.canonical_rondo == "4v4 plus 3" + + +def test_orphan_counterpress_deletion_is_idempotent_on_a_db_with_no_such_row( + fresh_db_url: str, +) -> None: + """The normal case: any database seeded on or after T-103 never had + a `counterpress` row to begin with, which is exactly why this bug is + invisible to a from-zero build or to CI. 0007's delete must match + zero rows and succeed rather than error, so a database with no + orphan is unaffected. test_fresh_db_builds_from_zero_via_the_full_ + migration_chain already exercises this end to end; this test isolates + the same guarantee at the 0006 -> 0007 step, with a counterpress_ring + row present to prove nothing else gets touched either.""" + cfg = _alembic_config() + command.upgrade(cfg, "0006") + + engine = create_engine(fresh_db_url) + with engine.begin() as conn: + _insert_formation_433(conn) + _insert_counterpress_ring_row(conn, "433") + engine.dispose() + + command.upgrade(cfg, "head") + + engine = create_engine(fresh_db_url) + with engine.connect() as conn: + rows = conn.execute( + text("SELECT zone_key FROM rondo_zones WHERE formation_code = '433'") + ).fetchall() + engine.dispose() + + assert {row.zone_key for row in rows} == {"counterpress_ring"} diff --git a/backend/tests/test_seed_content.py b/backend/tests/test_seed_content.py index 5eeaddc..ee8b90d 100644 --- a/backend/tests/test_seed_content.py +++ b/backend/tests/test_seed_content.py @@ -280,8 +280,11 @@ def table_counts(session) -> dict[str, int]: "library_items": 23, "formations": 6, "formation_keystones": 13, - "rondo_zones": 6, # T-101/migration 0006 split flank_corridor into left/right - "identities": 27, + # T-101/migration 0006 split flank_corridor into left/right; T-103 + # then seeded doc 06 section 2.3's six zones on all six formations + # (6 x 6), and added ten reference systems to the 27 identities. + "rondo_zones": 36, + "identities": 37, } exit_code_2 = seed.main() diff --git a/backend/tests/test_slot_unit_crosswalk.py b/backend/tests/test_slot_unit_crosswalk.py new file mode 100644 index 0000000..11581ca --- /dev/null +++ b/backend/tests/test_slot_unit_crosswalk.py @@ -0,0 +1,741 @@ +"""T-110 slot-to-unit crosswalk and the coach-only unit balance evaluation +(doc 06 sections 2.6, 3.1 and 5.3). + +Four halves, matching the four things the ticket actually claims: + + 1. Seed content. Every formation slot declares a slot_family from doc 06 + section 2.6's ten, and the football in those assignments is asserted + per formation rather than pattern-matched off the slot id (a 3-4-3's + wide player is a wing back, a 4-3-3's is a fullback, a back three's + outer defenders are cb_wide and its middle one cb_central). + 2. Negative validator tests. The validator must REJECT a missing + slot_family and one outside the ten, proven by running the real + validator against a mutated copy of seeds/ (the harness convention + test_tactics_seed_content.py established: a green validator over good + data proves nothing about the rule). + 3. app/units.py as a pure function, no database. Most importantly: a + 4-3-3 evaluates midfield_three, back_line, front_three and wide_unit + and does NOT evaluate double_pivot or strike_pair. Firing "this + double pivot has no tempo setter" at a shape with no double pivot + would discredit every other warning on the page. + 4. The API surface, including the 403 a player token gets. + +Regression guard on the shared seed file: T-103's 33 formation_phases rows +bind to the base formation by slot id, so this ticket adding a FIELD to +each position must not disturb the slot set, the position codes or the +coordinates. Asserted directly below rather than left to the validator. +""" + +from __future__ import annotations + +import contextlib +import importlib.util +import io +import itertools +import json +import pathlib +import shutil +import sys +from collections.abc import Callable, Iterator +from dataclasses import dataclass, field + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from app.db import SessionLocal +from app.main import app +from app.models import Formation, PositionArchetype, UnitBalanceRule +from app.units import ( + SLOT_FAMILY_UNITS, + UNITS, + evaluate_unit_balance, + flank_of, + unit_membership, + units_absent, + units_present, +) + +REPO_ROOT = pathlib.Path(__file__).resolve().parents[2] +SEEDS = REPO_ROOT / "seeds" +VALIDATOR = REPO_ROOT / "scripts" / "validate_seeds.py" + +FORMATIONS_FILE = "formations.json" +PHASES_FILE = "formation_phases.json" +ARCHETYPES_FILE = "position_archetypes.json" +RULES_FILE = "unit_balance_rules.json" + +# doc 06 section 2.6, verbatim. +SLOT_FAMILIES = { + "gk", "cb_central", "cb_wide", "fb", "wb", + "six", "eight", "ten", "wide_forward", "nine", +} + + +def _load(name: str) -> dict: + return json.loads((SEEDS / name).read_text(encoding="utf-8")) + + +def _items(name: str) -> list[dict]: + return _load(name)["items"] + + +def _formation(code: str) -> dict: + return next(f for f in _items(FORMATIONS_FILE) if f["code"] == code) + + +def _families(code: str) -> dict[str, str]: + return {p["slot"]: p["slot_family"] for p in _formation(code)["positions_json"]} + + +# --------------------------------------------------------------------------- +# 1. Seed content +# --------------------------------------------------------------------------- + + +def test_every_formation_slot_declares_a_family_from_the_ten() -> None: + for formation in _items(FORMATIONS_FILE): + positions = formation["positions_json"] + assert len(positions) == 11, formation["code"] + for position in positions: + family = position.get("slot_family") + assert family in SLOT_FAMILIES, f"{formation['code']}.{position['slot']}: {family}" + + +def test_every_slot_family_used_by_a_formation_has_archetypes_to_pick_from() -> None: + """An empty picker is a dead slot in doc 06 section 5.3's panel.""" + seeded = {a["slot_family"] for a in _items(ARCHETYPES_FILE)} + used = { + p["slot_family"] + for f in _items(FORMATIONS_FILE) + for p in f["positions_json"] + } + assert used <= seeded, f"no archetypes for: {sorted(used - seeded)}" + + +def test_a_back_three_splits_into_wide_and_central_centre_backs() -> None: + """The reason slot_family is seeded rather than derived: position_code + is CB for all three, and the outer two do a different job from the + middle one. T-102's bl_stepping_back_three combination (cb_wide, + cb_central, cb_wide) only has a home if this is right.""" + for code in ("352", "343", "541"): + families = _families(code) + assert families["cb_l"] == "cb_wide", code + assert families["cb_c"] == "cb_central", code + assert families["cb_r"] == "cb_wide", code + + +def test_a_back_four_has_two_central_centre_backs_and_two_fullbacks() -> None: + for code in ("433", "4231", "442"): + families = _families(code) + assert families["cb_l"] == "cb_central", code + assert families["cb_r"] == "cb_central", code + assert families["fb_l"] == "fb", code + assert families["fb_r"] == "fb", code + + +def test_a_wing_back_shape_seeds_wing_backs_not_fullbacks() -> None: + """The football the ticket calls out by name: a 3-4-3's wide player is + a wing back, a 4-3-3's is a fullback, and the two families carry + genuinely different archetypes (wb_flyer versus fb_inverter).""" + for code in ("352", "343", "541"): + families = _families(code) + assert families["wb_l"] == "wb", code + assert families["wb_r"] == "wb", code + assert _families("433")["fb_l"] == "fb" + + +def test_a_six_and_an_eight_are_told_apart_where_position_code_cannot() -> None: + """4-3-3: six plus two eights, all three of which position_code calls + DM/CM/CM. 3-5-2: the middle one holds, the outer two shuttle.""" + four_three_three = _families("433") + assert four_three_three["six"] == "six" + assert four_three_three["eight_l"] == "eight" + assert four_three_three["eight_r"] == "eight" + + three_five_two = _families("352") + assert three_five_two["cm_c"] == "six" + assert three_five_two["cm_l"] == "eight" + assert three_five_two["cm_r"] == "eight" + + +def test_the_only_ten_in_the_library_is_the_4231s() -> None: + tens = { + (f["code"], p["slot"]) + for f in _items(FORMATIONS_FILE) + for p in f["positions_json"] + if p["slot_family"] == "ten" + } + assert tens == {("4231", "am")} + + +def test_adding_slot_family_changed_no_slot_id_position_code_or_coordinate() -> None: + """T-103's 33 formation_phases rows bind to the base formation by slot + id and are validated against its position codes. This ticket added a + field; it must not have moved anything.""" + expected = { + "433": [ + ("gk", "GK", 5, 50), ("cb_l", "CB", 20, 35), ("cb_r", "CB", 20, 65), + ("fb_l", "FB", 22, 12), ("fb_r", "FB", 22, 88), ("six", "DM", 42, 50), + ("eight_l", "CM", 55, 30), ("eight_r", "CM", 55, 70), + ("w_l", "W", 78, 15), ("st", "ST", 85, 50), ("w_r", "W", 78, 85), + ], + "541": [ + ("gk", "GK", 5, 50), ("wb_l", "WB", 20, 8), ("cb_l", "CB", 15, 28), + ("cb_c", "CB", 13, 50), ("cb_r", "CB", 15, 72), ("wb_r", "WB", 20, 92), + ("cm_l", "CM", 45, 25), ("cm_cl", "CM", 43, 42), ("cm_cr", "CM", 43, 58), + ("cm_r", "CM", 45, 75), ("st", "ST", 80, 50), + ], + } + for code, rows in expected.items(): + actual = [ + (p["slot"], p["position_code"], p["x"], p["y"]) + for p in _formation(code)["positions_json"] + ] + assert actual == rows, code + + +def test_all_33_phase_rows_still_carry_their_base_formations_slot_set() -> None: + """The hard validator rule of doc 06 section 3.1, restated here so a + seed edit that breaks the morph animation fails in `make test` too and + not only in `make check-copy`.""" + base = { + f["code"]: {p["slot"]: p["position_code"] for p in f["positions_json"]} + for f in _items(FORMATIONS_FILE) + } + phases = _items(PHASES_FILE) + assert len(phases) == 33 + for phase in phases: + key = f"{phase['formation_code']}.{phase['variant_code']}" + seeded = {p["slot"]: p["position_code"] for p in phase["positions_json"]} + assert seeded == base[phase["formation_code"]], key + + +# --------------------------------------------------------------------------- +# 2. Negative validator tests +# --------------------------------------------------------------------------- + +_module_counter = itertools.count() + + +def _fresh_validator(seeds_dir: pathlib.Path): + """A fresh module object per run: the validator accumulates into a + module-level `errors` list, so a reused import would leak one test's + failures into the next.""" + name = f"pop_validate_seeds_t110_{next(_module_counter)}" + spec = importlib.util.spec_from_file_location(name, VALIDATOR) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + module.SEEDS = seeds_dir + return module + + +def _run_validator(tmp_path: pathlib.Path, mutate: Callable[[pathlib.Path], None]) -> tuple[int, str]: + seeds_copy = tmp_path / "seeds" + shutil.copytree(SEEDS, seeds_copy) + mutate(seeds_copy) + module = _fresh_validator(seeds_copy) + buffer = io.StringIO() + with contextlib.redirect_stdout(buffer): + code = module.main() + return code, buffer.getvalue() + + +def _edit_position(seeds_dir: pathlib.Path, formation_code: str, slot: str, **fields) -> None: + path = seeds_dir / FORMATIONS_FILE + data = json.loads(path.read_text(encoding="utf-8")) + formation = next(f for f in data["items"] if f["code"] == formation_code) + position = next(p for p in formation["positions_json"] if p["slot"] == slot) + for key, value in fields.items(): + if value is _DROP: + position.pop(key, None) + else: + position[key] = value + path.write_text(json.dumps(data, indent=2), encoding="utf-8") + + +_DROP = object() + + +def test_the_negative_harness_passes_on_unmutated_seeds(tmp_path: pathlib.Path) -> None: + code, out = _run_validator(tmp_path, lambda _: None) + assert code == 0, out + assert "all checks passed" in out + + +def test_validator_rejects_a_missing_slot_family(tmp_path: pathlib.Path) -> None: + code, out = _run_validator( + tmp_path, + lambda seeds: _edit_position(seeds, "433", "six", slot_family=_DROP), + ) + assert code == 1 + assert "slot 'six' is missing required field 'slot_family'" in out + + +def test_validator_rejects_a_slot_family_outside_the_ten(tmp_path: pathlib.Path) -> None: + code, out = _run_validator( + tmp_path, + lambda seeds: _edit_position(seeds, "433", "six", slot_family="regista"), + ) + assert code == 1 + assert "slot_family 'regista' not in" in out + + +def test_validator_rejects_a_family_no_archetype_belongs_to(tmp_path: pathlib.Path) -> None: + """Valid vocabulary, empty picker: 'ten' is one of the ten families, so + the vocabulary check passes, but if no archetype belonged to it the + 4-2-3-1's am would open on an empty list.""" + + def mutate(seeds: pathlib.Path) -> None: + path = seeds / ARCHETYPES_FILE + data = json.loads(path.read_text(encoding="utf-8")) + data["items"] = [i for i in data["items"] if i["slot_family"] != "ten"] + path.write_text(json.dumps(data, indent=2), encoding="utf-8") + + code, out = _run_validator(tmp_path, mutate) + assert code == 1 + assert "slot_family 'ten' is used by a formation slot but no" in out + + +# --------------------------------------------------------------------------- +# 3. app/units.py, pure +# --------------------------------------------------------------------------- + + +def test_the_crosswalk_covers_every_slot_family_exactly_once() -> None: + assert set(SLOT_FAMILY_UNITS) == SLOT_FAMILIES + for family, units in SLOT_FAMILY_UNITS.items(): + assert set(units) <= set(UNITS), family + + +def test_a_fullback_belongs_to_two_units_at_once() -> None: + """The reason unit membership is a list and not a lookup: doc 06 + section 2.6 evaluates a fullback in the back line AND on his flank.""" + assert set(SLOT_FAMILY_UNITS["fb"]) == {"back_line", "wide_unit"} + assert set(SLOT_FAMILY_UNITS["wb"]) == {"back_line", "wide_unit"} + + +def test_box_midfield_is_reachable_from_no_family() -> None: + """doc 06 section 2.6 gives box_midfield no framework and T-102 + deliberately seeded it no combinations and no rules. It stays in the + vocabulary and out of the crosswalk rather than being invented here.""" + assert all("box_midfield" not in units for units in SLOT_FAMILY_UNITS.values()) + + +def test_flank_comes_from_the_coordinate_not_the_slot_id() -> None: + assert flank_of(12) == "left" + assert flank_of(88) == "right" + assert flank_of(50) == "center" + + +def test_a_433_evaluates_four_units_and_not_a_double_pivot_or_a_strike_pair() -> None: + """The ticket's headline guarantee. A 4-3-3 has no double pivot and no + strike pair, so neither may be evaluated: an empty unit would trip + every requires_duty rule attached to it and shout about a pivot pair + that does not exist.""" + positions = _formation("433")["positions_json"] + assert units_present(positions) == [ + "midfield_three", + "front_three", + "back_line", + "wide_unit", + ] + absent = units_absent(positions) + assert "double_pivot" in absent + assert "strike_pair" in absent + assert "box_midfield" in absent + + +@pytest.mark.parametrize( + ("code", "expected"), + [ + ("433", {"midfield_three", "front_three", "back_line", "wide_unit"}), + ("4231", {"double_pivot", "front_three", "back_line", "wide_unit"}), + ("442", {"double_pivot", "strike_pair", "back_line", "wide_unit"}), + # 3-5-2: a flank holding only a wing back is not a "fullback plus + # wide forward" pair, so it is not a wide unit (doc 06 section 2.6). + ("352", {"midfield_three", "strike_pair", "back_line"}), + ("343", {"double_pivot", "front_three", "back_line", "wide_unit"}), + # 5-4-1: a flat midfield four matches no doc 06 unit and a lone + # striker is not a unit. Recorded as the shape's real gap rather + # than papered over by inventing a framework doc 06 does not give. + ("541", {"back_line"}), + ], +) +def test_unit_membership_per_formation(code: str, expected: set[str]) -> None: + assert set(units_present(_formation(code)["positions_json"])) == expected + + +def test_the_wide_unit_occurs_once_per_touchline() -> None: + instances = [i for i in unit_membership(_formation("343")["positions_json"]) if i.unit == "wide_unit"] + assert [(i.flank, i.slots) for i in instances] == [ + ("left", ("wb_l", "w_l")), + ("right", ("wb_r", "w_r")), + ] + + +def test_a_back_five_is_a_back_line_of_five_including_the_wing_backs() -> None: + back = next(i for i in unit_membership(_formation("352")["positions_json"]) if i.unit == "back_line") + assert back.slots == ("cb_l", "cb_c", "cb_r", "wb_l", "wb_r") + + +def test_a_flat_442_is_a_strike_pair_not_a_front_three() -> None: + """Counting {wide_forward, nine} together would call two wide + midfielders plus two strikers a front four.""" + present = units_present(_formation("442")["positions_json"]) + assert "strike_pair" in present + assert "front_three" not in present + + +# --- the evaluator --------------------------------------------------------- + + +@dataclass +class FakeArchetype: + code: str + name: str = "Archetype" + duties_json: list = field(default_factory=list) + + +@dataclass +class FakeRule: + code: str + unit: str + rule_kind: str + duty: str | None = None + min_count: int | None = None + max_count: int | None = None + warning_copy: str = "Check that this is the plan here." + severity: str = "warning" + + +_NEEDS_TEMPO = FakeRule( + code="mt_needs_a_tempo_setter", + unit="midfield_three", + rule_kind="requires_duty", + duty="tempo", + min_count=1, +) +_ONE_BOX_THREAT = FakeRule( + code="mt_one_box_threat", + unit="midfield_three", + rule_kind="max_duty", + duty="box_threat", + max_count=1, + severity="note", +) +_ONE_OF_EACH = FakeRule( + code="mt_one_of_each_archetype", + unit="midfield_three", + rule_kind="max_same_archetype", + max_count=1, + severity="note", +) +_PIVOT_NEEDS_TEMPO = FakeRule( + code="dp_needs_a_controller", + unit="double_pivot", + rule_kind="requires_duty", + duty="tempo", + min_count=1, +) + +_CRASHER = FakeArchetype("eight_box_crasher", "Box crasher", ["box_threat"]) +_CREATOR = FakeArchetype("eight_half_space_creator", "Half-space creator", ["progression"]) +_METRONOME = FakeArchetype("six_metronome", "Metronome", ["tempo", "rest_defence"]) +_ARCHETYPES = {a.code: a for a in (_CRASHER, _CREATOR, _METRONOME)} + +_ALL_RULES = [_NEEDS_TEMPO, _ONE_BOX_THREAT, _ONE_OF_EACH, _PIVOT_NEEDS_TEMPO] + + +def _evaluate_433(assignments: dict[str, str | None], rules: list | None = None): + return { + (e.unit, e.flank): e + for e in evaluate_unit_balance( + _formation("433")["positions_json"], + assignments, + _ARCHETYPES, # type: ignore[arg-type] + rules if rules is not None else _ALL_RULES, # type: ignore[arg-type] + ) + } + + +def test_nothing_assigned_is_silent_and_not_an_error() -> None: + """doc 06 section 5.3: a coach planning a shape at 11pm does not want + to fill in a roster first. Every unit still comes back, listed and + quiet.""" + result = _evaluate_433({}) + assert set(result) == { + ("midfield_three", None), + ("front_three", None), + ("back_line", None), + ("wide_unit", "left"), + ("wide_unit", "right"), + } + assert all(not e.notes for e in result.values()) + assert all(not e.is_complete for e in result.values()) + + +def test_a_half_assigned_unit_does_not_fire_a_requires_duty_rule() -> None: + """Half-assigned is unfinished, not imbalanced: the third midfielder + might still be the metronome the trio is missing.""" + result = _evaluate_433({"six": "eight_half_space_creator", "eight_l": "eight_box_crasher"}) + trio = result[("midfield_three", None)] + assert trio.is_complete is False + assert [n.code for n in trio.notes] == [] + + +def test_a_complete_trio_with_no_tempo_setter_fires_the_seeded_check() -> None: + result = _evaluate_433( + { + "six": "eight_half_space_creator", + "eight_l": "eight_box_crasher", + "eight_r": "eight_half_space_creator", + } + ) + trio = result[("midfield_three", None)] + assert trio.is_complete is True + codes = {n.code for n in trio.notes} + assert "mt_needs_a_tempo_setter" in codes + # Same archetype twice: the mirror check, and it carries the seeded + # copy rather than copy composed in code. + assert "mt_one_of_each_archetype" in codes + assert all(n.message == "Check that this is the plan here." for n in trio.notes) + assert {n.severity for n in trio.notes} == {"warning", "note"} + + +def test_a_max_duty_rule_fires_before_the_unit_is_complete() -> None: + """max_* rules are monotone: two box crashers already in the trio stay + two however the third slot is filled, so flagging early is still true.""" + result = _evaluate_433({"eight_l": "eight_box_crasher", "eight_r": "eight_box_crasher"}) + trio = result[("midfield_three", None)] + assert trio.is_complete is False + assert "mt_one_box_threat" in {n.code for n in trio.notes} + + +def test_a_double_pivot_rule_never_fires_on_a_433() -> None: + """The guarantee restated at the evaluator level: the 4-3-3's three + central midfielders are a midfield three, so the double pivot rules + have nothing to run against, complete assignment or not.""" + result = _evaluate_433( + { + "six": "six_metronome", + "eight_l": "eight_half_space_creator", + "eight_r": "eight_box_crasher", + } + ) + assert ("double_pivot", None) not in result + every_code = {n.code for e in result.values() for n in e.notes} + assert "dp_needs_a_controller" not in every_code + + +def test_a_note_carries_the_flank_when_the_unit_has_one() -> None: + rule = FakeRule( + code="wu_needs_width", + unit="wide_unit", + rule_kind="requires_duty", + duty="width", + min_count=1, + ) + result = _evaluate_433( + {"fb_l": "eight_half_space_creator", "w_l": "eight_box_crasher"}, rules=[rule] + ) + left = result[("wide_unit", "left")] + assert left.is_complete is True + assert [(n.code, n.flank) for n in left.notes] == [("wu_needs_width", "left")] + assert not result[("wide_unit", "right")].notes + + +def test_the_real_seeded_rules_stay_quiet_on_a_balanced_433_midfield() -> None: + """Against the actual seeded content, not fakes: doc 06 section 2.6's + 'metronome, creator, crasher' is the positional-possession trio and + should raise no WARNING at all.""" + archetypes = {a["code"]: FakeArchetype(a["code"], a["name"], a["duties_json"]) for a in _items(ARCHETYPES_FILE)} + rules = [FakeRule(**{k: r[k] for k in ("code", "unit", "rule_kind", "duty", "min_count", "max_count", "warning_copy", "severity")}) for r in _items(RULES_FILE)] + evaluations = evaluate_unit_balance( + _formation("433")["positions_json"], + { + "six": "six_metronome", + "eight_l": "eight_half_space_creator", + "eight_r": "eight_box_crasher", + }, + archetypes, # type: ignore[arg-type] + rules, # type: ignore[arg-type] + ) + trio = next(e for e in evaluations if e.unit == "midfield_three") + assert [n.code for n in trio.notes if n.severity == "warning"] == [] + + +# --------------------------------------------------------------------------- +# 4. The API +# --------------------------------------------------------------------------- + + +@pytest.fixture +def db() -> Iterator[Session]: + session = SessionLocal() + try: + yield session + finally: + session.close() + + +def _register(client: TestClient, *, email: str, role: str, display_name: str = "Test User"): + return client.post( + "/api/auth/register", + json={ + "email": email, + "password": "correct-horse-battery", + "display_name": display_name, + "role": role, + }, + ) + + +def _coach_with_team() -> TestClient: + c = TestClient(app) + _register(c, email="coach@example.com", role="coach", display_name="Coach Test") + c.post("/api/teams", json={"name": "Balance FC"}) + return c + + +def _player_on_team(coach: TestClient) -> TestClient: + join_code = coach.get("/api/teams/current").json()["join_code"] + p = TestClient(app) + _register(p, email="player@example.com", role="player", display_name="Player Test") + p.post("/api/teams/join", json={"join_code": join_code}) + return p + + +@pytest.fixture +def seeded(db: Session) -> None: + """The real seeded 4-3-3, archetypes and balance rules, loaded straight + from seeds/ so these tests prove the shipped content rather than a + hand-built fixture that could drift from it.""" + formation = _formation("433") + db.add( + Formation( + code=formation["code"], + name=formation["name"], + shape_blurb=formation["shape_blurb"], + positions_json=formation["positions_json"], + ) + ) + for a in _items(ARCHETYPES_FILE): + db.add( + PositionArchetype( + code=a["code"], + slot_family=a["slot_family"], + name=a["name"], + definition=a["definition"], + key_attribute_keys=a["key_attribute_keys"], + foot_hint=a.get("foot_hint"), + awr_default=a["awr_default"], + dwr_default=a["dwr_default"], + duties_json=a["duties_json"], + needs_around_it=a["needs_around_it"], + ) + ) + for r in _items(RULES_FILE): + db.add( + UnitBalanceRule( + code=r["code"], + unit=r["unit"], + rule_kind=r["rule_kind"], + duty=r.get("duty"), + min_count=r.get("min_count"), + max_count=r.get("max_count"), + warning_copy=r["warning_copy"], + severity=r["severity"], + ) + ) + db.commit() + + +def test_balance_endpoint_is_403_for_a_player_token(seeded: None) -> None: + """doc 06 section 5.3: unit balance is coach-only "both in the UI and + at the API". A player gets 403, not an empty list, the same standing + the roster fit warnings have (CLAUDE.md rule 5).""" + coach = _coach_with_team() + player = _player_on_team(coach) + body = {"slots": [{"slot": "six", "archetype_code": "six_metronome"}]} + + assert coach.post("/api/formations/433/balance", json=body).status_code == 200 + assert player.post("/api/formations/433/balance", json=body).status_code == 403 + + +def test_balance_returns_the_units_a_433_has_and_names_the_ones_it_does_not(seeded: None) -> None: + coach = _coach_with_team() + response = coach.post("/api/formations/433/balance", json={"slots": []}) + assert response.status_code == 200 + payload = response.json() + + assert payload["formation_code"] == "433" + assert [(u["unit"], u["flank"]) for u in payload["units"]] == [ + ("midfield_three", None), + ("front_three", None), + ("back_line", None), + ("wide_unit", "left"), + ("wide_unit", "right"), + ] + assert "double_pivot" in payload["units_not_evaluated"] + assert "strike_pair" in payload["units_not_evaluated"] + # Empty roster / nothing assigned is a first-class 200, and silent. + assert all(u["notes"] == [] for u in payload["units"]) + + +def test_balance_fires_the_seeded_copy_on_an_unbalanced_trio(seeded: None) -> None: + coach = _coach_with_team() + response = coach.post( + "/api/formations/433/balance", + json={ + "slots": [ + {"slot": "six", "archetype_code": "six_destroyer"}, + {"slot": "eight_l", "archetype_code": "eight_box_crasher"}, + {"slot": "eight_r", "archetype_code": "eight_box_crasher"}, + ] + }, + ) + assert response.status_code == 200 + trio = next(u for u in response.json()["units"] if u["unit"] == "midfield_three") + assert trio["is_complete"] is True + codes = {n["code"] for n in trio["notes"]} + assert "mt_needs_a_tempo_setter" in codes + assert "mt_one_box_threat" in codes + seeded_copy = {r["code"]: r["warning_copy"] for r in _items(RULES_FILE)} + for note in trio["notes"]: + assert note["message"] == seeded_copy[note["code"]] + + +def test_balance_rejects_a_slot_that_is_not_in_the_formation(seeded: None) -> None: + coach = _coach_with_team() + response = coach.post( + "/api/formations/433/balance", + json={"slots": [{"slot": "am", "archetype_code": "ten_between_the_lines"}]}, + ) + assert response.status_code == 422 + + +def test_balance_rejects_an_unknown_archetype_code(seeded: None) -> None: + coach = _coach_with_team() + response = coach.post( + "/api/formations/433/balance", + json={"slots": [{"slot": "six", "archetype_code": "six_libero"}]}, + ) + assert response.status_code == 422 + + +def test_balance_404s_an_unknown_formation(seeded: None) -> None: + coach = _coach_with_team() + assert coach.post("/api/formations/4141/balance", json={"slots": []}).status_code == 404 + + +def test_balance_never_supplies_team_id_from_the_client(seeded: None) -> None: + """CLAUDE.md rule 4: the body has no team_id field at all, so a forged + one is rejected outright rather than ignored.""" + coach = _coach_with_team() + response = coach.post( + "/api/formations/433/balance", json={"slots": [], "team_id": 9999} + ) + assert response.status_code == 422 diff --git a/backend/tests/test_tactics_phase_seed_content.py b/backend/tests/test_tactics_phase_seed_content.py new file mode 100644 index 0000000..2cac1e1 --- /dev/null +++ b/backend/tests/test_tactics_phase_seed_content.py @@ -0,0 +1,720 @@ +"""T-103 Tactics Lab seed content (doc 06 sections 2.3, 2.4, 2.5, 2.8, 3.1): +formation_phases, rotation_systems, the six-zone rondo map on all six +formations, formation_matchups, and the ten reference systems seeded as +identities of kind 'reference_system'. + +Same two halves as backend/tests/test_tactics_seed_content.py (T-102), for +the same reason: content assertions over the seed files prove the football +is there, and negative tests against a mutated copy of seeds/ prove +scripts/validate_seeds.py actually rejects the violations rather than +merely having a rule written down. Plus a loader test, because "the loader +is wired up" is a claim worth proving. + +The two rules the ticket calls out by name get one test per failure mode: + + - a phase whose positions_json slot set differs from its base + formation's, tested three ways (a slot added, a slot dropped, a slot + renamed), because the morph animation binds by slot and a renamed slot + is two failures at once rather than one. + - a rotation with an empty risk, and a rotation with no risk key at all. + A rotation library that lists only benefits is marketing, not coaching. +""" + +from __future__ import annotations + +import contextlib +import importlib.util +import io +import itertools +import json +import pathlib +import shutil +import sys +from collections.abc import Callable + +import pytest + +REPO_ROOT = pathlib.Path(__file__).resolve().parents[2] +SEEDS = REPO_ROOT / "seeds" +VALIDATOR = REPO_ROOT / "scripts" / "validate_seeds.py" + +PHASES_FILE = "formation_phases.json" +ROTATIONS_FILE = "rotation_systems.json" +MATCHUPS_FILE = "formation_matchups.json" +RONDO_FILE = "rondo_zones.json" +REF_SYSTEMS_FILE = "identities_reference_systems.json" +FORMATIONS_FILE = "formations.json" + +NEW_FILES = [PHASES_FILE, ROTATIONS_FILE, MATCHUPS_FILE, REF_SYSTEMS_FILE] +ALL_T103_FILES = NEW_FILES + [RONDO_FILE] + +FORMATION_CODES = ["433", "4231", "442", "352", "343", "541"] + +# doc 06 section 2.3, verbatim. +RONDO_ZONE_KEYS = { + "first_line", "midfield_box", "flank_corridor_left", "flank_corridor_right", + "last_line", "counterpress_ring", +} +# doc 06 section 2.5, all fourteen. +DOC06_ROTATION_CODES = [ + "rot_invert_fb_pivot", "rot_invert_fb_high", "rot_cb_step", "rot_cb_invert_middle", + "rot_pivot_drop", "rot_double_pivot_split", "rot_wb_asymmetry", "rot_fb_touchline_swap", + "rot_false_nine_drop", "rot_box_form", "rot_press_bait_hold", "rot_gk_plus_one", + "rot_ten_drop_pivot", "rot_overload_isolate", +] +ROTATION_FAMILIES = {"first_line", "pivot", "wide", "front_line"} +ROUTE_KINDS = {"through", "around", "over"} + + +def _load(name: str) -> dict: + return json.loads((SEEDS / name).read_text(encoding="utf-8")) + + +def _items(name: str) -> list[dict]: + return _load(name)["items"] + + +def _base_slots() -> dict[str, dict[str, str]]: + return { + f["code"]: {p["slot"]: p["position_code"] for p in f["positions_json"]} + for f in _items(FORMATIONS_FILE) + } + + +# --------------------------------------------------------------------------- +# Content: formation_phases +# --------------------------------------------------------------------------- + + +def test_every_formation_carries_between_three_and_five_phase_variants() -> None: + """The ticket's own shape: six formations at three to five variants + each, before the attributed reference-system rows are counted.""" + base = {} + for item in _items(PHASES_FILE): + if item["variant_code"].startswith("ref_"): + continue + base.setdefault(item["formation_code"], []).append(item["variant_code"]) + assert set(base) == set(FORMATION_CODES) + for code, variants in base.items(): + assert 3 <= len(variants) <= 5, f"{code}: {len(variants)} variants" + + +def test_every_phase_carries_the_exact_slot_set_of_its_base_formation() -> None: + """doc 06 section 3.1's hard rule. The morph animation binds by slot, + so a phase that adds, drops or renames one cannot animate.""" + base = _base_slots() + for item in _items(PHASES_FILE): + seeded = {p["slot"]: p["position_code"] for p in item["positions_json"]} + key = f"{item['formation_code']}.{item['variant_code']}" + assert set(seeded) == set(base[item["formation_code"]]), key + assert len(item["positions_json"]) == 11, key + + +def test_slots_never_change_identity_across_phases() -> None: + """A left back who walks into midfield is still the left back. If the + position_code moved with him the coach would be watching a token + change job rather than a player change position.""" + base = _base_slots() + for item in _items(PHASES_FILE): + for p in item["positions_json"]: + assert p["position_code"] == base[item["formation_code"]][p["slot"]], ( + f"{item['formation_code']}.{item['variant_code']}.{p['slot']}" + ) + + +def test_every_phase_position_is_a_landscape_model_coordinate() -> None: + """CLAUDE.md rule 8: x 0 to 100 toward the attacking goal, y 0 to 100 + top to bottom. Orientation is render-only and never seeded.""" + for item in _items(PHASES_FILE): + for p in item["positions_json"]: + assert 0 <= p["x"] <= 100 and 0 <= p["y"] <= 100, item["variant_code"] + + +def test_left_is_low_y_and_right_is_high_y_in_every_phase() -> None: + """seeds/formations.json's own _l/_r convention, which T-101's corridor + split (flank_corridor_left at y 0 to 25) also follows. A phase that + mirrored it would render every wide rotation on the wrong flank.""" + for item in _items(PHASES_FILE): + pos = {p["slot"]: p["y"] for p in item["positions_json"]} + for left, right in (("cb_l", "cb_r"), ("fb_l", "fb_r"), ("wb_l", "wb_r"), + ("cm_l", "cm_r"), ("w_l", "w_r"), ("wm_l", "wm_r"), + ("st_l", "st_r"), ("eight_l", "eight_r"), ("dm_l", "dm_r")): + if left in pos and right in pos: + assert pos[left] < pos[right], ( + f"{item['formation_code']}.{item['variant_code']}: {left} is not left of {right}" + ) + + +def test_every_phase_blurb_is_one_sentence_within_the_word_limit() -> None: + for item in _items(PHASES_FILE): + blurb = item["blurb"] + assert len(blurb.split()) <= 25, f"{item['variant_code']}: {len(blurb.split())} words" + assert blurb.count(".") == 1, f"{item['variant_code']}: not one sentence" + + +def test_every_rest_defence_variant_states_a_rest_shape() -> None: + for item in _items(PHASES_FILE): + if item["phase"] == "rest_defence": + assert item["rest_shape"], item["variant_code"] + + +def test_every_phase_rotation_reference_resolves() -> None: + codes = {i["code"] for i in _items(ROTATIONS_FILE)} + for item in _items(PHASES_FILE): + for rc in item["uses_rotations"]: + assert rc in codes, f"{item['variant_code']}: unknown rotation '{rc}'" + + +# --------------------------------------------------------------------------- +# Content: rotation_systems +# --------------------------------------------------------------------------- + + +def test_all_fourteen_rotation_systems_doc06_names_are_seeded() -> None: + codes = [i["code"] for i in _items(ROTATIONS_FILE)] + assert sorted(codes) == sorted(DOC06_ROTATION_CODES) + + +def test_every_rotation_states_what_it_costs() -> None: + """doc 06 section 2.5: 'A rotation with no stated risk is marketing, + not coaching.'""" + for item in _items(ROTATIONS_FILE): + risk = item["risk"] + assert risk and len(risk.split()) >= 8, f"{item['code']}: risk is empty or thin" + + +def test_every_rotation_animates_and_loops() -> None: + """doc 03 section 4.1: rotations are the same animation format with + loop true, so they play continuously on the board.""" + for item in _items(ROTATIONS_FILE): + spec = item["animation_spec_json"] + assert spec and spec["loop"] is True, item["code"] + assert spec["slots"] and spec["steps"], item["code"] + + +def test_every_moved_slot_exists_in_the_rotations_animation_spec() -> None: + """what_moves_json and animation_spec_json describe the same movement. + A slot in one and not the other is a rotation that cannot be played.""" + for item in _items(ROTATIONS_FILE): + spec_slots = {s["slot"] for s in item["animation_spec_json"]["slots"]} + for move in item["what_moves_json"]: + assert move["slot"] in spec_slots, f"{item['code']}: {move['slot']}" + assert move["becomes"], f"{item['code']}: {move['slot']} becomes nothing" + + +def test_every_rotation_family_and_formation_reference_is_real() -> None: + for item in _items(ROTATIONS_FILE): + assert item["family"] in ROTATION_FAMILIES, item["code"] + assert item["applies_to_formations"], item["code"] + for fc in item["applies_to_formations"]: + assert fc in FORMATION_CODES, f"{item['code']}: {fc}" + + +def test_every_rotation_profile_names_real_archetypes_and_attributes() -> None: + archetypes = {i["code"] for i in _items("position_archetypes.json")} + six = {"pace", "passing_range", "carrying_1v1", + "positional_discipline", "aerial_physical", "pressing_engine"} + for item in _items(ROTATIONS_FILE): + for slot, need in (item["requires_profile_json"] or {}).items(): + assert set(need.get("archetypes") or []) <= archetypes, f"{item['code']}.{slot}" + assert set(need.get("attributes") or []) <= six, f"{item['code']}.{slot}" + assert need.get("foot") in (None, "L", "R"), f"{item['code']}.{slot}" + + +def test_rotation_exemplar_notes_carry_the_standing_disclaimer_or_say_nothing() -> None: + """seeds/roles.json's convention, and T-102's. A null note claims + nothing, which is the honest option when unsure of a name.""" + for item in _items(ROTATIONS_FILE): + note = item["exemplar_note"] + if note is None: + continue + assert note.endswith("Not a licence: names are editorial reference points only."), item["code"] + + +# --------------------------------------------------------------------------- +# Content: rondo map +# --------------------------------------------------------------------------- + + +def test_every_formation_carries_all_six_rondo_zones() -> None: + by_formation: dict[str, set[str]] = {} + for item in _items(RONDO_FILE): + by_formation.setdefault(item["formation_code"], set()).add(item["zone_key"]) + assert set(by_formation) == set(FORMATION_CODES) + for code, keys in by_formation.items(): + assert keys == RONDO_ZONE_KEYS, f"{code}: missing {RONDO_ZONE_KEYS - keys}" + + +def test_the_old_counterpress_zone_key_is_gone() -> None: + """doc 06 section 2.3 renames it and changes what it is. The seeded + 4-3-3 row carried the old key from Bible 3G.2.""" + keys = {i["zone_key"] for i in _items(RONDO_FILE)} + assert "counterpress" not in keys + assert "counterpress_ring" in keys + + +def test_the_counterpress_ring_is_a_ball_relative_circle_everywhere() -> None: + """The teaching point of the zone: rest defence is relative to the + ball, not to the pitch.""" + rings = [i for i in _items(RONDO_FILE) if i["zone_key"] == "counterpress_ring"] + assert len(rings) == len(FORMATION_CODES) + for item in rings: + assert item["zone_kind"] == "ball_relative_circle", item["formation_code"] + assert item["radius"] == 18, item["formation_code"] + + +def test_polygon_zones_carry_no_radius() -> None: + for item in _items(RONDO_FILE): + if item["zone_kind"] == "polygon": + assert item["radius"] is None, f"{item['formation_code']}.{item['zone_key']}" + + +def test_every_rondo_zone_carries_a_no_opposition_fallback_label() -> None: + """canonical_rondo is the label shown when no opposition is placed. The + live ratio is computed, never seeded (doc 06 section 2.3).""" + for item in _items(RONDO_FILE): + assert item["canonical_rondo"], f"{item['formation_code']}.{item['zone_key']}" + + +def test_flank_corridors_sit_on_the_side_their_key_names() -> None: + """Left is low y, right is high y, matching formations.json's _l/_r + convention and T-101's own corridor split.""" + for item in _items(RONDO_FILE): + ys = [p["y"] for p in item["polygon_json"]] + if item["zone_key"] == "flank_corridor_left": + assert max(ys) <= 50, item["formation_code"] + if item["zone_key"] == "flank_corridor_right": + assert min(ys) >= 50, item["formation_code"] + + +# --------------------------------------------------------------------------- +# Content: matchups and reference systems +# --------------------------------------------------------------------------- + + +def test_every_matchup_is_normalised_and_unique() -> None: + seen = set() + for item in _items(MATCHUPS_FILE): + pair = (item["ours_code"], item["theirs_code"]) + assert pair[0] < pair[1], f"{pair}: not normalised" + assert pair not in seen, f"{pair}: duplicate" + seen.add(pair) + + +def test_every_matchup_card_teaches_all_three_steps_of_the_read() -> None: + """doc 06 section 2.8: where our spare man is, where we are short, and + which route connects them. A card missing a step teaches something + different from every other card.""" + for item in _items(MATCHUPS_FILE): + key = f"{item['ours_code']} v {item['theirs_code']}" + assert len(item["our_edges_json"]) >= 2, key + assert len(item["their_edges_json"]) >= 2, key + assert len(item["route"].split()) >= 10, key + assert item["route_kind"] in ROUTE_KINDS, key + + +def test_all_ten_reference_systems_are_seeded_as_identities() -> None: + data = _load(REF_SYSTEMS_FILE) + assert data["kind"] == "reference_system" + assert len(data["items"]) == 10 + + +def test_every_reference_system_names_a_phase_variant_that_exists() -> None: + """doc 06 section 2.5: each card carries the phase variant it produces. + identities has no column for one, so the link is a formation_phases row + pointing back, and a card nothing points at describes nothing.""" + referenced = {i["reference_code"] for i in _items(PHASES_FILE) if i["reference_code"]} + for item in _items(REF_SYSTEMS_FILE): + assert item["code"] in referenced, item["code"] + + +def test_every_reference_system_states_its_rotations_risk_and_provenance() -> None: + for item in _items(REF_SYSTEMS_FILE): + core = item["core_idea"] + assert core.startswith("Formation:"), item["code"] + for marker in ("Rotations:", "Risk:", "Provenance:"): + assert marker in core, f"{item['code']}: no '{marker}' line" + assert item["keystone_roles_json"], item["code"] + assert item["youth_takeaway"], item["code"] + + +# --------------------------------------------------------------------------- +# Copy rules across everything this ticket seeds +# --------------------------------------------------------------------------- + + +def test_no_em_dash_in_any_t103_seed_file() -> None: + """CLAUDE.md rule 3. The character is built with chr() rather than + typed, so this test does not itself trip scripts/check_copy.py.""" + em_dash = chr(0x2014) + for fname in ALL_T103_FILES: + assert em_dash not in (SEEDS / fname).read_text(encoding="utf-8"), fname + + +def test_identity_copy_bans_apply_to_every_t103_file() -> None: + """doc 03 section 7.6 / CLAUDE.md: identities curate, they never lock.""" + for phrase in ["correct", "right way", "off-identity"]: + for fname in ALL_T103_FILES: + blob = (SEEDS / fname).read_text(encoding="utf-8").lower() + assert phrase not in blob, f"{fname}: contains '{phrase}'" + + +def test_every_t103_row_carries_source_ref_and_content_version() -> None: + for fname in ALL_T103_FILES: + data = _load(fname) + for item in data["items"]: + label = item.get("code") or f"{item.get('formation_code')}.{item.get('variant_code')}" + assert item["source_ref"].startswith(("doc06:", "bible:")), f"{fname} {label}" + assert item["content_version"] == data["content_version"], f"{fname} {label}" + + +# --------------------------------------------------------------------------- +# The validator actually rejects each violation +# --------------------------------------------------------------------------- + +_module_counter = itertools.count() + + +def _fresh_validator(seeds_dir: pathlib.Path): + name = f"pop_validate_seeds_t103_{next(_module_counter)}" + spec = importlib.util.spec_from_file_location(name, VALIDATOR) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + module.SEEDS = seeds_dir + return module + + +def _run_validator(tmp_path: pathlib.Path, mutate: Callable[[pathlib.Path], None]) -> tuple[int, str]: + seeds_copy = tmp_path / "seeds" + shutil.copytree(SEEDS, seeds_copy) + mutate(seeds_copy) + module = _fresh_validator(seeds_copy) + buffer = io.StringIO() + with contextlib.redirect_stdout(buffer): + code = module.main() + return code, buffer.getvalue() + + +def _mutate_item(seeds_dir: pathlib.Path, fname: str, index: int, fn: Callable[[dict], None]) -> None: + path = seeds_dir / fname + data = json.loads(path.read_text(encoding="utf-8")) + fn(data["items"][index]) + path.write_text(json.dumps(data, indent=2), encoding="utf-8") + + +def test_the_negative_harness_passes_on_unmutated_seeds(tmp_path: pathlib.Path) -> None: + code, out = _run_validator(tmp_path, lambda _: None) + assert code == 0, out + assert "all checks passed" in out + + +# --- the phase slot-set rule, one test per failure mode -------------------- + + +def test_validator_rejects_a_phase_with_an_added_slot(tmp_path: pathlib.Path) -> None: + def mutate(seeds: pathlib.Path) -> None: + _mutate_item( + seeds, PHASES_FILE, 0, + lambda item: item["positions_json"].append( + {"slot": "libero", "position_code": "CB", "x": 30, "y": 50} + ), + ) + + code, out = _run_validator(tmp_path, mutate) + assert code == 1 + assert "positions_json has slot(s) ['libero'] that the base formation does not have" in out + + +def test_validator_rejects_a_phase_with_a_dropped_slot(tmp_path: pathlib.Path) -> None: + def mutate(seeds: pathlib.Path) -> None: + _mutate_item(seeds, PHASES_FILE, 0, lambda item: item["positions_json"].pop()) + + code, out = _run_validator(tmp_path, mutate) + assert code == 1 + assert "positions_json is missing base formation slot(s)" in out + + +def test_validator_rejects_a_phase_with_a_renamed_slot(tmp_path: pathlib.Path) -> None: + """A rename is the failure the morph animation actually suffers: the + count still says eleven, so only a set comparison catches it.""" + def mutate(seeds: pathlib.Path) -> None: + def rename(item: dict) -> None: + item["positions_json"][1]["slot"] = "left_centre_back" + + _mutate_item(seeds, PHASES_FILE, 0, rename) + + code, out = _run_validator(tmp_path, mutate) + assert code == 1 + assert "positions_json has slot(s) ['left_centre_back'] that the base formation does not have" in out + assert "positions_json is missing base formation slot(s)" in out + + +def test_validator_rejects_a_phase_whose_slot_changes_position_code(tmp_path: pathlib.Path) -> None: + def mutate(seeds: pathlib.Path) -> None: + _mutate_item( + seeds, PHASES_FILE, 0, + lambda item: item["positions_json"][1].update({"position_code": "DM"}), + ) + + code, out = _run_validator(tmp_path, mutate) + assert code == 1 + assert "in the base formation" in out + + +# --- the rotation risk rule ------------------------------------------------ + + +def test_validator_rejects_a_rotation_with_an_empty_risk(tmp_path: pathlib.Path) -> None: + code, out = _run_validator( + tmp_path, + lambda seeds: _mutate_item(seeds, ROTATIONS_FILE, 0, lambda item: item.update({"risk": ""})), + ) + assert code == 1 + assert "missing required field 'risk'" in out + + +def test_validator_rejects_a_rotation_with_no_risk_key_at_all(tmp_path: pathlib.Path) -> None: + code, out = _run_validator( + tmp_path, + lambda seeds: _mutate_item(seeds, ROTATIONS_FILE, 0, lambda item: item.pop("risk")), + ) + assert code == 1 + assert "missing required field 'risk'" in out + + +def test_validator_rejects_a_rotation_whose_risk_is_a_token_gesture(tmp_path: pathlib.Path) -> None: + code, out = _run_validator( + tmp_path, + lambda seeds: _mutate_item(seeds, ROTATIONS_FILE, 0, lambda item: item.update({"risk": "Some risk."})), + ) + assert code == 1 + assert "risk is 2 words, too thin to be a real cost" in out + + +# --- the rest of the new rules -------------------------------------------- + + +def test_validator_rejects_a_moved_slot_missing_from_the_animation_spec(tmp_path: pathlib.Path) -> None: + code, out = _run_validator( + tmp_path, + lambda seeds: _mutate_item( + seeds, ROTATIONS_FILE, 0, + lambda item: item["what_moves_json"][0].update({"slot": "ghost"}), + ), + ) + assert code == 1 + assert "slot 'ghost' is not defined in animation_spec_json" in out + + +def test_validator_rejects_a_phase_pointing_at_an_unknown_rotation(tmp_path: pathlib.Path) -> None: + code, out = _run_validator( + tmp_path, + lambda seeds: _mutate_item( + seeds, PHASES_FILE, 0, lambda item: item.update({"uses_rotations": ["rot_nonexistent"]}) + ), + ) + assert code == 1 + assert "uses_rotations references unknown rotation system 'rot_nonexistent'" in out + + +def test_validator_rejects_an_unnormalised_matchup(tmp_path: pathlib.Path) -> None: + def mutate(seeds: pathlib.Path) -> None: + _mutate_item( + seeds, MATCHUPS_FILE, 0, + lambda item: item.update({"ours_code": "541", "theirs_code": "343"}), + ) + + code, out = _run_validator(tmp_path, mutate) + assert code == 1 + assert "must be normalised with ours_code < theirs_code" in out + + +def test_validator_rejects_a_polygon_zone_carrying_a_radius(tmp_path: pathlib.Path) -> None: + code, out = _run_validator( + tmp_path, + lambda seeds: _mutate_item(seeds, RONDO_FILE, 0, lambda item: item.update({"radius": 12})), + ) + assert code == 1 + assert "a polygon zone must not carry a radius" in out + + +def test_validator_rejects_a_formation_missing_a_rondo_zone(tmp_path: pathlib.Path) -> None: + def mutate(seeds: pathlib.Path) -> None: + path = seeds / RONDO_FILE + data = json.loads(path.read_text(encoding="utf-8")) + data["items"] = [i for i in data["items"] if i["zone_key"] != "counterpress_ring" + or i["formation_code"] != "352"] + path.write_text(json.dumps(data, indent=2), encoding="utf-8") + + code, out = _run_validator(tmp_path, mutate) + assert code == 1 + assert "352: missing rondo zone(s) ['counterpress_ring']" in out + + +def test_validator_rejects_a_reference_system_nothing_points_at(tmp_path: pathlib.Path) -> None: + def mutate(seeds: pathlib.Path) -> None: + path = seeds / PHASES_FILE + data = json.loads(path.read_text(encoding="utf-8")) + for item in data["items"]: + if item["reference_code"] == "ref_man_city_325": + item["reference_code"] = None + path.write_text(json.dumps(data, indent=2), encoding="utf-8") + + code, out = _run_validator(tmp_path, mutate) + assert code == 1 + assert "no formation_phases row carries reference_code 'ref_man_city_325'" in out + + +def test_validator_rejects_a_reference_system_with_no_risk_line(tmp_path: pathlib.Path) -> None: + def mutate(seeds: pathlib.Path) -> None: + def strip(item: dict) -> None: + head, _, _ = item["core_idea"].partition("Risk:") + item["core_idea"] = head + + _mutate_item(seeds, REF_SYSTEMS_FILE, 0, strip) + + code, out = _run_validator(tmp_path, mutate) + assert code == 1 + assert "core_idea is missing its 'Risk:' line" in out + + +def test_validator_rejects_an_over_long_phase_blurb(tmp_path: pathlib.Path) -> None: + code, out = _run_validator( + tmp_path, + lambda seeds: _mutate_item( + seeds, PHASES_FILE, 0, lambda item: item.update({"blurb": "word " * 30}), + ), + ) + assert code == 1 + assert "over the 25-word limit" in out + + +# --------------------------------------------------------------------------- +# Loader +# --------------------------------------------------------------------------- + + +def _import_seed_module(): + spec = importlib.util.spec_from_file_location( + "pop_seed_script_t103", REPO_ROOT / "scripts" / "seed.py" + ) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _count_teams() -> int: + from app.db import SessionLocal + from app.models import Team + + session = SessionLocal() + try: + return session.query(Team).count() + finally: + session.close() + + +def test_seed_loader_loads_every_new_file_into_a_fresh_database() -> None: + """The DoD line, proven rather than asserted: run the real loader + against the empty test database, count rows against the seed files, + read one row of each table back out, then run it again to prove the + upsert updates in place instead of duplicating.""" + seed = _import_seed_module() + + from app.db import SessionLocal + from app.models import ( + FormationMatchup, + FormationPhase, + Identity, + RondoZone, + RotationSystem, + ) + + assert seed.main() == 0 + + session = SessionLocal() + try: + first = { + "formation_phases": session.query(FormationPhase).count(), + "rotation_systems": session.query(RotationSystem).count(), + "formation_matchups": session.query(FormationMatchup).count(), + "rondo_zones": session.query(RondoZone).count(), + "reference_systems": session.query(Identity) + .filter(Identity.kind == "reference_system") + .count(), + } + + phase = session.get(FormationPhase, ("433", "in_possession")) + assert phase is not None + assert phase.shape_label == "3-2-5" + assert phase.rest_shape == "3+2" + assert phase.uses_rotations == ["rot_invert_fb_pivot"] + assert {p["slot"] for p in phase.positions_json} == { + "gk", "cb_l", "cb_r", "fb_l", "fb_r", "six", + "eight_l", "eight_r", "w_l", "st", "w_r", + } + + rotation = session.get(RotationSystem, "rot_gk_plus_one") + assert rotation is not None + assert rotation.family == "first_line" + assert rotation.risk + assert rotation.animation_spec_json["loop"] is True + + matchup = session.get(FormationMatchup, ("433", "442")) + assert matchup is not None + assert matchup.route_kind == "through" + assert matchup.our_edges_json and matchup.their_edges_json + + ring = session.get(RondoZone, ("541", "counterpress_ring")) + assert ring is not None + assert ring.zone_kind == "ball_relative_circle" + assert ring.radius == 18 + assert ring.canonical_rondo + + city = session.query(Identity).filter(Identity.code == "ref_man_city_325").one() + assert city.kind == "reference_system" + assert city.formation_code == "433" + finally: + session.close() + + assert first == { + "formation_phases": len(_items(PHASES_FILE)), + "rotation_systems": len(_items(ROTATIONS_FILE)), + "formation_matchups": len(_items(MATCHUPS_FILE)), + "rondo_zones": len(_items(RONDO_FILE)), + "reference_systems": len(_items(REF_SYSTEMS_FILE)), + } + + teams_before = _count_teams() + assert seed.main() == 0 + + session = SessionLocal() + try: + second = { + "formation_phases": session.query(FormationPhase).count(), + "rotation_systems": session.query(RotationSystem).count(), + "formation_matchups": session.query(FormationMatchup).count(), + "rondo_zones": session.query(RondoZone).count(), + "reference_systems": session.query(Identity) + .filter(Identity.kind == "reference_system") + .count(), + } + finally: + session.close() + + assert second == first, "re-running the seeder must upsert, not duplicate" + assert _count_teams() == teams_before, "the seeder must never touch team-scoped tables" + + +@pytest.mark.parametrize("fname", NEW_FILES) +def test_seed_loader_knows_about_every_new_file(fname: str) -> None: + """A seed file absent from LOAD_ORDER falls through to the unordered + tail, which for these files means loading before the rows they point + at exist.""" + seed = _import_seed_module() + assert _load(fname)["table"] in seed.TABLE_CONFIG + assert fname in seed.LOAD_ORDER diff --git a/backend/tests/test_tactics_routes.py b/backend/tests/test_tactics_routes.py new file mode 100644 index 0000000..5e888e5 --- /dev/null +++ b/backend/tests/test_tactics_routes.py @@ -0,0 +1,742 @@ +"""Tactics Lab API routes (doc 06 sections 3.2, 5.3, 6; T-108): formation +phases, formation matchups, rotations, position archetypes, the coach-only +archetype suggestion ranking, and team formation persistence. + +T-101 created every table this ticket serves EMPTY (seed content is +T-102/T-103's job, running concurrently, not this ticket's). Every test +below builds its own fixture rows directly through app.db.SessionLocal +(same convention as test_scoped_query_layer.py) rather than depending on +scripts/seed.py, so these tests are correct against both an empty table +(today) and a seeded one (once T-102/T-103 land). + +Covers, per CLAUDE.md rule 4/5 and this ticket's DoD: + - every team-world route resolves team_id through get_team_scope, never + a client field (team_formations direct, team_formation_slots + transitive through team_formation_id, matching + test_scoped_query_layer.py's lower-level proof one level up at the API) + - one 403 test PER coach-only route, not one test for the group: + GET /api/archetypes/suggest, POST /api/team-formations, + PUT /api/team-formations/{id} + - empty roster and empty tables are first-class 200 states, never 404 +""" + +from collections.abc import Iterator + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from app.db import SessionLocal +from app.main import app +from app.models import Formation, FormationMatchup, FormationPhase, PositionArchetype, RotationSystem + +# --------------------------------------------------------------------------- +# Shared fixtures (same register/team/join helper block as every other +# permission-adjacent test file in this suite, per its own convention). +# --------------------------------------------------------------------------- + + +@pytest.fixture +def client() -> TestClient: + return TestClient(app) + + +@pytest.fixture +def db() -> Iterator[Session]: + session = SessionLocal() + try: + yield session + finally: + session.close() + + +def _register(client: TestClient, *, email: str, role: str, display_name: str = "Test User"): + return client.post( + "/api/auth/register", + json={ + "email": email, + "password": "correct-horse-battery", + "display_name": display_name, + "role": role, + }, + ) + + +def _coach_with_team(email: str = "coach@example.com", name: str = "Coach Test") -> TestClient: + c = TestClient(app) + _register(c, email=email, role="coach", display_name=name) + c.post("/api/teams", json={"name": f"Team for {email}"}) + return c + + +def _player_on_team(coach: TestClient, email: str, name: str = "Player Test") -> TestClient: + join_code = coach.get("/api/teams/current").json()["join_code"] + p = TestClient(app) + _register(p, email=email, role="player", display_name=name) + p.post("/api/teams/join", json={"join_code": join_code}) + return p + + +_POSITIONS_433 = [ + {"slot": "gk", "position_code": "GK", "slot_family": "gk", "x": 5.0, "y": 50.0}, + {"slot": "cb_l", "position_code": "CB", "slot_family": "cb_central", "x": 20.0, "y": 35.0}, + {"slot": "cb_r", "position_code": "CB", "slot_family": "cb_central", "x": 20.0, "y": 65.0}, +] + + +def _formation(db, code: str, name: str = "Formation") -> Formation: + row = Formation(code=code, name=name, shape_blurb="test", positions_json=_POSITIONS_433) + db.add(row) + db.commit() + return row + + +def _phase( + db, + formation_code: str, + variant_code: str, + *, + phase: str = "in_possession", + uses_rotations: list | None = None, +) -> FormationPhase: + row = FormationPhase( + formation_code=formation_code, + variant_code=variant_code, + phase=phase, + name=f"{variant_code} shape", + shape_label="3-2-5", + blurb="A short blurb.", + positions_json=_POSITIONS_433, + trigger="when the ball reaches the first line", + rest_shape="3+2", + reference_code=None, + uses_rotations=uses_rotations or [], + ) + db.add(row) + db.commit() + return row + + +def _rotation(db, code: str, *, family: str = "pivot", applies_to_formations: list | None = None) -> RotationSystem: + row = RotationSystem( + code=code, + name=f"{code} rotation", + family=family, + applies_to_formations=applies_to_formations or [], + produces_shape="3-2-5", + trigger="fullback steps in", + what_moves_json=[], + coaching_points_json=["Time the drop."], + risk="Leaves the flank uncovered on the turnover.", + requires_profile_json=None, + animation_spec_json=None, + exemplar_note=None, + ) + db.add(row) + db.commit() + return row + + +def _archetype( + db, + code: str, + *, + slot_family: str = "CB", + key_attribute_keys: list | None = None, + foot_hint: str | None = None, + awr_default: str = "med", + dwr_default: str = "med", + name: str | None = None, +) -> PositionArchetype: + row = PositionArchetype( + code=code, + slot_family=slot_family, + name=name or code.replace("_", " ").title(), + definition="A defined role.", + key_attribute_keys=key_attribute_keys or ["pace", "positional_discipline"], + foot_hint=foot_hint, + awr_default=awr_default, + dwr_default=dwr_default, + duties_json=[], + enables_pattern_codes=[], + enables_rotation_codes=[], + needs_around_it="cover behind", + exemplar_note=None, + ) + db.add(row) + db.commit() + return row + + +def _matchup( + db, ours_code: str, theirs_code: str, *, route: str = "Through the half spaces.", route_kind: str = "through" +) -> FormationMatchup: + row = FormationMatchup( + ours_code=ours_code, + theirs_code=theirs_code, + our_edges_json=["Our 8 exploits their gap between lines."], + their_edges_json=["Their winger isolates our fullback."], + route=route, + route_kind=route_kind, + ) + db.add(row) + db.commit() + return row + + +_ATTRS = { + "pace": 2, + "passing_range": 2, + "carrying_1v1": 2, + "positional_discipline": 2, + "aerial_physical": 2, + "pressing_engine": 2, +} + + +def _player_body(**overrides: object) -> dict: + base: dict = { + "name": "New Player", + "jersey_number": 10, + "preferred_foot": "R", + "role_code": None, + "flank": None, + "awr": "med", + "dwr": "med", + "attributes": _ATTRS, + } + base.update(overrides) + return base + + +# --------------------------------------------------------------------------- +# GET /api/formations/{code}/phases +# --------------------------------------------------------------------------- + + +def test_list_formation_phases_requires_authentication(client: TestClient, db) -> None: + _formation(db, "433", "4-3-3") + assert client.get("/api/formations/433/phases").status_code == 401 + + +def test_list_formation_phases_404_for_unknown_formation_code(client: TestClient, db) -> None: + coach = _coach_with_team() + assert coach.get("/api/formations/999/phases").status_code == 404 + + +def test_list_formation_phases_empty_table_is_200_empty_list(client: TestClient, db) -> None: + _formation(db, "433", "4-3-3") + coach = _coach_with_team() + response = coach.get("/api/formations/433/phases") + assert response.status_code == 200 + assert response.json() == [] + + +def test_list_formation_phases_returns_in_phase_order_with_positions_and_rotations( + client: TestClient, db +) -> None: + _formation(db, "433", "4-3-3") + # Inserted out of doc 06 section 3.1's own phase order (out_of_possession + # before in_possession) to prove the route sorts, not the insert order. + _phase(db, "433", "low_block", phase="out_of_possession") + _phase(db, "433", "inverted_fb", phase="in_possession", uses_rotations=["fb_invert"]) + + coach = _coach_with_team() + response = coach.get("/api/formations/433/phases") + assert response.status_code == 200 + body = response.json() + assert [p["variant_code"] for p in body] == ["inverted_fb", "low_block"] + inverted = body[0] + assert inverted["uses_rotations"] == ["fb_invert"] + assert inverted["positions"] == _POSITIONS_433 + + +def test_players_can_read_formation_phases_too(client: TestClient, db) -> None: + _formation(db, "433", "4-3-3") + _phase(db, "433", "inverted_fb") + coach = _coach_with_team() + player = _player_on_team(coach, email="player@example.com") + assert coach.get("/api/formations/433/phases").json() == player.get("/api/formations/433/phases").json() + + +# --------------------------------------------------------------------------- +# GET /api/formations/matchup +# --------------------------------------------------------------------------- + + +def test_formation_matchup_requires_authentication(client: TestClient, db) -> None: + assert client.get("/api/formations/matchup", params={"ours": "433", "theirs": "442"}).status_code == 401 + + +def test_formation_matchup_404_for_unknown_formation_code(client: TestClient, db) -> None: + _formation(db, "433", "4-3-3") + coach = _coach_with_team() + response = coach.get("/api/formations/matchup", params={"ours": "433", "theirs": "999"}) + assert response.status_code == 404 + + +def test_formation_matchup_returns_null_when_no_seeded_card_not_404(client: TestClient, db) -> None: + """doc 06 section 2: an unseeded pair is a normal state ("say plainly + that this pair has no coached read yet"), never a 404.""" + _formation(db, "433", "4-3-3") + _formation(db, "442", "4-4-2") + coach = _coach_with_team() + response = coach.get("/api/formations/matchup", params={"ours": "433", "theirs": "442"}) + assert response.status_code == 200 + body = response.json() + assert body["ours_code"] == "433" + assert body["theirs_code"] == "442" + assert body["matchup"] is None + + +def test_formation_matchup_resolves_regardless_of_query_order(client: TestClient, db) -> None: + """formation_matchups stores one row per UNORDERED pair (doc 06 + section 3.1: 15 pairs, not 30). Querying either direction must resolve + to the SAME stored row, presented exactly as authored (not swapped): + see app/routers/tactics.py's module docstring for why this ticket does + not invent a perspective-swap for the untested reverse direction.""" + _formation(db, "343", "3-4-3") + _formation(db, "433", "4-3-3") + _matchup(db, "343", "433", route="Go through their half space.") + + coach = _coach_with_team() + forward = coach.get("/api/formations/matchup", params={"ours": "343", "theirs": "433"}).json() + reverse = coach.get("/api/formations/matchup", params={"ours": "433", "theirs": "343"}).json() + + for body in (forward, reverse): + assert body["matchup"]["ours_code"] == "343" + assert body["matchup"]["theirs_code"] == "433" + assert body["matchup"]["route"] == "Go through their half space." + assert body["matchup"]["route_kind"] == "through" + assert body["matchup"]["our_edges"] == ["Our 8 exploits their gap between lines."] + assert body["matchup"]["their_edges"] == ["Their winger isolates our fullback."] + + +# --------------------------------------------------------------------------- +# GET /api/rotations +# --------------------------------------------------------------------------- + + +def test_rotations_empty_table_is_200_empty_list(client: TestClient, db) -> None: + coach = _coach_with_team() + response = coach.get("/api/rotations") + assert response.status_code == 200 + assert response.json() == [] + + +def test_rotations_requires_authentication(client: TestClient, db) -> None: + assert client.get("/api/rotations").status_code == 401 + + +def test_rotations_lists_all_and_filters_by_formation_code(client: TestClient, db) -> None: + _rotation(db, "fb_invert", family="pivot", applies_to_formations=["433"]) + _rotation(db, "winger_underlap", family="wide", applies_to_formations=["442"]) + + coach = _coach_with_team() + all_rotations = coach.get("/api/rotations").json() + assert {r["code"] for r in all_rotations} == {"fb_invert", "winger_underlap"} + # family order (first_line, pivot, wide, front_line) beats insertion order. + assert [r["code"] for r in all_rotations] == ["fb_invert", "winger_underlap"] + + filtered = coach.get("/api/rotations", params={"formation_code": "433"}).json() + assert [r["code"] for r in filtered] == ["fb_invert"] + + assert coach.get("/api/rotations", params={"formation_code": "541"}).json() == [] + + +def test_players_can_read_rotations_too(client: TestClient, db) -> None: + _rotation(db, "fb_invert") + coach = _coach_with_team() + player = _player_on_team(coach, email="player@example.com") + assert coach.get("/api/rotations").json() == player.get("/api/rotations").json() + + +# --------------------------------------------------------------------------- +# GET /api/archetypes +# --------------------------------------------------------------------------- + + +def test_archetypes_empty_table_is_200_empty_list(client: TestClient, db) -> None: + coach = _coach_with_team() + response = coach.get("/api/archetypes") + assert response.status_code == 200 + assert response.json() == [] + + +def test_archetypes_lists_all_and_filters_by_slot_family(client: TestClient, db) -> None: + _archetype(db, "metronome", slot_family="DM") + _archetype(db, "stopper", slot_family="CB") + + coach = _coach_with_team() + all_archetypes = coach.get("/api/archetypes").json() + assert {a["code"] for a in all_archetypes} == {"metronome", "stopper"} + + filtered = coach.get("/api/archetypes", params={"slot_family": "DM"}).json() + assert [a["code"] for a in filtered] == ["metronome"] + + +def test_players_can_read_archetypes_too(client: TestClient, db) -> None: + _archetype(db, "metronome") + coach = _coach_with_team() + player = _player_on_team(coach, email="player@example.com") + assert coach.get("/api/archetypes").json() == player.get("/api/archetypes").json() + + +# --------------------------------------------------------------------------- +# GET /api/archetypes/suggest, coach-only (dedicated 403 test per this +# ticket's "one 403 test PER route" instruction). +# --------------------------------------------------------------------------- + + +def test_archetype_suggest_requires_coach_role__player_403(client: TestClient, db) -> None: + _archetype(db, "metronome", slot_family="DM") + coach = _coach_with_team() + player = _player_on_team(coach, email="player@example.com") + response = player.get("/api/archetypes/suggest", params={"slot_family": "DM"}) + assert response.status_code == 403 + + +def test_archetype_suggest_empty_candidates_is_200_empty_list_not_404(client: TestClient, db) -> None: + coach = _coach_with_team() + response = coach.get("/api/archetypes/suggest", params={"slot_family": "DM"}) + assert response.status_code == 200 + assert response.json()["suggestions"] == [] + + +def test_archetype_suggest_empty_roster_state_without_player_id(client: TestClient, db) -> None: + """doc 06 section 5.3: "Empty roster is a first-class state: the panel + still works with archetypes alone and no players assigned.\"""" + _archetype(db, "metronome", slot_family="DM") + _archetype(db, "destroyer", slot_family="DM") + coach = _coach_with_team() + response = coach.get("/api/archetypes/suggest", params={"slot_family": "DM"}) + assert response.status_code == 200 + body = response.json() + assert body["player_id"] is None + codes = [s["archetype_code"] for s in body["suggestions"]] + assert set(codes) == {"metronome", "destroyer"} + assert all("No player assigned yet" in s["why"] for s in body["suggestions"]) + + +def test_archetype_suggest_404_for_unknown_player(client: TestClient, db) -> None: + _archetype(db, "metronome", slot_family="DM") + coach = _coach_with_team() + response = coach.get("/api/archetypes/suggest", params={"slot_family": "DM", "player_id": 999}) + assert response.status_code == 404 + + +def test_archetype_suggest_cross_team_player_404(client: TestClient, db) -> None: + """A coach cannot rank suggestions against another team's roster row; + scope.get() resolves it to nothing rather than leaking cross-team data + (same "cross-team read returns nothing" contract as app/scoped.py).""" + _archetype(db, "metronome", slot_family="DM") + coach_a = _coach_with_team(email="coach-a@example.com") + other_player_id = coach_a.post("/api/roster/players", json=_player_body(name="Team A Player")).json()["id"] + + coach_b = _coach_with_team(email="coach-b@example.com") + response = coach_b.get( + "/api/archetypes/suggest", params={"slot_family": "DM", "player_id": other_player_id} + ) + assert response.status_code == 404 + + +def test_archetype_suggest_ranks_by_attribute_fit_and_cites_the_actual_reason( + client: TestClient, db +) -> None: + """doc 06 section 5.3: rank first by attribute fit against + key_attribute_keys, and "the why must cite the actual reason ..., not + a score.\"""" + _archetype( + db, + "metronome", + slot_family="DM", + name="The Metronome", + key_attribute_keys=["passing_range", "positional_discipline"], + ) + _archetype( + db, + "destroyer", + slot_family="DM", + name="The Destroyer", + key_attribute_keys=["pressing_engine", "aerial_physical"], + ) + + coach = _coach_with_team() + player_id = coach.post( + "/api/roster/players", + json=_player_body( + name="Deep Lying Playmaker", + attributes={ + "pace": 2, + "passing_range": 5, + "carrying_1v1": 2, + "positional_discipline": 4, + "aerial_physical": 1, + "pressing_engine": 1, + }, + ), + ).json()["id"] + + response = coach.get( + "/api/archetypes/suggest", params={"slot_family": "DM", "player_id": player_id} + ) + assert response.status_code == 200 + body = response.json() + assert body["player_id"] == player_id + codes = [s["archetype_code"] for s in body["suggestions"]] + assert codes[0] == "metronome" # higher attribute_score (5+4=9 vs 1+1=2) + + metronome_why = next(s["why"] for s in body["suggestions"] if s["archetype_code"] == "metronome") + assert "passing range 5" in metronome_why + assert "positional discipline 4" in metronome_why + assert "the metronome" in metronome_why + # Cites the real values, never a bare numeric score. + assert "score" not in metronome_why.lower() + + +def test_archetype_suggest_foot_fit_cited_when_it_decides_the_why(client: TestClient, db) -> None: + """doc 06 section 5.3's second ranking criterion: "foot fit against + foot_hint and the slot's side.\"""" + _archetype( + db, + "inverted_fb", + slot_family="FB", + name="Inverted Fullback", + key_attribute_keys=["passing_range", "positional_discipline"], + foot_hint="opposite_side", + ) + + coach = _coach_with_team() + # Left-back, right-footed: opposite_side foot_hint fits. + player_id = coach.post( + "/api/roster/players", + json=_player_body(name="Left Back", preferred_foot="R", flank="left", attributes=_ATTRS), + ).json()["id"] + + response = coach.get( + "/api/archetypes/suggest", params={"slot_family": "FB", "player_id": player_id} + ) + why = response.json()["suggestions"][0]["why"] + assert "opposite side" in why + + +def test_archetype_suggest_awr_dwr_match_cited(client: TestClient, db) -> None: + """doc 06 section 5.3's third ranking criterion: "AWR/DWR match.\"""" + _archetype( + db, + "box_to_box", + slot_family="CM", + name="Box To Box", + key_attribute_keys=["pace"], + awr_default="high", + dwr_default="high", + ) + coach = _coach_with_team() + player_id = coach.post( + "/api/roster/players", + json=_player_body(name="Engine", awr="high", dwr="high", attributes=_ATTRS), + ).json()["id"] + response = coach.get( + "/api/archetypes/suggest", params={"slot_family": "CM", "player_id": player_id} + ) + why = response.json()["suggestions"][0]["why"] + assert "work rate matches the role both ways" in why + + +def test_archetype_suggest_limits_to_top_three(client: TestClient, db) -> None: + for i in range(5): + _archetype(db, f"cb_{i}", slot_family="CB", key_attribute_keys=["pace"]) + coach = _coach_with_team() + player_id = coach.post("/api/roster/players", json=_player_body(attributes=_ATTRS)).json()["id"] + response = coach.get( + "/api/archetypes/suggest", params={"slot_family": "CB", "player_id": player_id} + ) + assert len(response.json()["suggestions"]) == 3 + + +# --------------------------------------------------------------------------- +# Team formation persistence (doc 06 section 3.2): create/update coach-only +# (dedicated 403 tests per route below), read open to both roles. +# --------------------------------------------------------------------------- + + +def _tf_body(**overrides: object) -> dict: + base: dict = { + "name": "Saturday setup", + "base_formation_code": "433", + "active_phase_variant": "in_possession", + "opponent_formation_code": None, + "opponent_phase_variant": None, + "slots": [], + } + base.update(overrides) + return base + + +def test_create_team_formation_requires_coach_role__player_403(client: TestClient, db) -> None: + _formation(db, "433", "4-3-3") + coach = _coach_with_team() + player = _player_on_team(coach, email="player@example.com") + response = player.post("/api/team-formations", json=_tf_body()) + assert response.status_code == 403 + + +def test_update_team_formation_requires_coach_role__player_403(client: TestClient, db) -> None: + _formation(db, "433", "4-3-3") + coach = _coach_with_team() + player = _player_on_team(coach, email="player@example.com") + created = coach.post("/api/team-formations", json=_tf_body()).json() + response = player.put(f"/api/team-formations/{created['id']}", json=_tf_body(name="Forged")) + assert response.status_code == 403 + # The forged write did not take. + assert coach.get(f"/api/team-formations/{created['id']}").json()["name"] == "Saturday setup" + + +def test_create_and_read_team_formation_round_trip_with_resolved_names( + client: TestClient, db +) -> None: + _formation(db, "433", "4-3-3") + _archetype(db, "metronome", slot_family="DM", name="The Metronome") + coach = _coach_with_team() + player_id = coach.post("/api/roster/players", json=_player_body(name="Sam Anchor")).json()["id"] + + created = coach.post( + "/api/team-formations", + json=_tf_body( + slots=[ + { + "slot": "six", + "player_id": player_id, + "archetype_code": "metronome", + "qualitative_edge": True, + } + ] + ), + ) + assert created.status_code == 201 + body = created.json() + assert body["name"] == "Saturday setup" + assert len(body["slots"]) == 1 + slot = body["slots"][0] + assert slot["slot"] == "six" + assert slot["player_name"] == "Sam Anchor" + assert slot["archetype_name"] == "The Metronome" + assert slot["qualitative_edge"] is True + + fetched = coach.get(f"/api/team-formations/{body['id']}").json() + assert fetched == body + + +def test_players_can_read_team_formations_too(client: TestClient, db) -> None: + _formation(db, "433", "4-3-3") + coach = _coach_with_team() + player = _player_on_team(coach, email="player@example.com") + coach.post("/api/team-formations", json=_tf_body()) + assert coach.get("/api/team-formations").json() == player.get("/api/team-formations").json() + + +def test_update_team_formation_replaces_the_whole_slot_set(client: TestClient, db) -> None: + _formation(db, "433", "4-3-3") + coach = _coach_with_team() + created = coach.post( + "/api/team-formations", json=_tf_body(slots=[{"slot": "six", "player_id": None, "archetype_code": None}]) + ).json() + + updated = coach.put( + f"/api/team-formations/{created['id']}", + json=_tf_body( + name="Renamed", + slots=[{"slot": "eight_l", "player_id": None, "archetype_code": None}], + ), + ) + assert updated.status_code == 200 + body = updated.json() + assert body["name"] == "Renamed" + assert [s["slot"] for s in body["slots"]] == ["eight_l"] + + +def test_create_team_formation_422_unknown_base_formation_code(client: TestClient, db) -> None: + coach = _coach_with_team() + response = coach.post("/api/team-formations", json=_tf_body(base_formation_code="999")) + assert response.status_code == 422 + + +def test_create_team_formation_422_unknown_player_id(client: TestClient, db) -> None: + _formation(db, "433", "4-3-3") + coach = _coach_with_team() + response = coach.post( + "/api/team-formations", + json=_tf_body(slots=[{"slot": "six", "player_id": 999, "archetype_code": None}]), + ) + assert response.status_code == 422 + + +def test_create_team_formation_422_unknown_archetype_code(client: TestClient, db) -> None: + _formation(db, "433", "4-3-3") + coach = _coach_with_team() + response = coach.post( + "/api/team-formations", + json=_tf_body(slots=[{"slot": "six", "player_id": None, "archetype_code": "does-not-exist"}]), + ) + assert response.status_code == 422 + + +def test_create_team_formation_422_duplicate_slot(client: TestClient, db) -> None: + _formation(db, "433", "4-3-3") + coach = _coach_with_team() + response = coach.post( + "/api/team-formations", + json=_tf_body( + slots=[ + {"slot": "six", "player_id": None, "archetype_code": None}, + {"slot": "six", "player_id": None, "archetype_code": None}, + ] + ), + ) + assert response.status_code == 422 + + +def test_create_team_formation_cannot_assign_another_teams_player(client: TestClient, db) -> None: + """CLAUDE.md rule 4: client input never supplies team_id, and a + player_id from another team resolves to nothing through the scope, the + same "cross-team read returns nothing" contract at the API layer.""" + _formation(db, "433", "4-3-3") + coach_a = _coach_with_team(email="coach-a@example.com") + other_player_id = coach_a.post("/api/roster/players", json=_player_body(name="Team A Player")).json()["id"] + + coach_b = _coach_with_team(email="coach-b@example.com") + response = coach_b.post( + "/api/team-formations", + json=_tf_body(slots=[{"slot": "six", "player_id": other_player_id, "archetype_code": None}]), + ) + assert response.status_code == 422 + + +def test_team_formation_cross_team_read_returns_404(client: TestClient, db) -> None: + """API-level companion to test_scoped_query_layer.py's lower-level + proof: a coach on a different team cannot fetch another team's saved + formation by id, and gets 404 (not the other team's data, not a 403 + that would even confirm the id exists).""" + _formation(db, "433", "4-3-3") + coach_a = _coach_with_team(email="coach-a@example.com") + team_a_formation_id = coach_a.post("/api/team-formations", json=_tf_body()).json()["id"] + + coach_b = _coach_with_team(email="coach-b@example.com") + assert coach_b.get(f"/api/team-formations/{team_a_formation_id}").status_code == 404 + assert coach_b.put(f"/api/team-formations/{team_a_formation_id}", json=_tf_body()).status_code == 404 + assert coach_b.get("/api/team-formations").json() == [] + + +def test_em_dash_never_appears_in_a_tactics_response(client: TestClient, db) -> None: + _formation(db, "433", "4-3-3") + _phase(db, "433", "inverted_fb") + _rotation(db, "fb_invert") + _archetype(db, "metronome") + coach = _coach_with_team() + for path, params in ( + ("/api/formations/433/phases", None), + ("/api/rotations", None), + ("/api/archetypes", None), + ("/api/archetypes/suggest", {"slot_family": "CB"}), + ): + assert "—" not in coach.get(path, params=params).text, path diff --git a/backend/tests/test_tactics_seed_content.py b/backend/tests/test_tactics_seed_content.py new file mode 100644 index 0000000..8b48931 --- /dev/null +++ b/backend/tests/test_tactics_seed_content.py @@ -0,0 +1,508 @@ +"""T-102 Tactics Lab seed content (doc 06 sections 2.6 and 3.1): +position_archetypes for all ten slot families, archetype_combinations, and +unit_balance_rules. + +Two halves, both of which the ticket's DoD asks for by name: + + 1. Content assertions over the three seed files themselves (every slot + family covered, the duty vocabulary closed, key_attribute_keys a 2 to + 3 subset of the six, every combination states a cost, warning copy + reads as a check). + 2. Negative tests that prove scripts/validate_seeds.py actually REJECTS + each of those violations. They run the real validator against a + mutated copy of seeds/ in a tmp dir, so a rule that silently stopped + firing fails here rather than shipping. This is the half that matters: + a green validator over good data proves nothing about the rule. + +Plus a loader test that seeds a fresh database and counts rows, because +"the loader is wired up" is a claim worth proving rather than asserting. +""" + +from __future__ import annotations + +import contextlib +import importlib.util +import io +import itertools +import json +import pathlib +import shutil +import sys +from collections.abc import Callable + +import pytest + +REPO_ROOT = pathlib.Path(__file__).resolve().parents[2] +SEEDS = REPO_ROOT / "seeds" +VALIDATOR = REPO_ROOT / "scripts" / "validate_seeds.py" + +ARCHETYPES_FILE = "position_archetypes.json" +COMBINATIONS_FILE = "archetype_combinations.json" +RULES_FILE = "unit_balance_rules.json" + +# doc 06 section 2.6, verbatim. +SLOT_FAMILIES = [ + "gk", "cb_central", "cb_wide", "fb", "wb", + "six", "eight", "ten", "wide_forward", "nine", +] +DUTY_VOCABULARY = { + "tempo", "progression", "rest_defence", "width", "pin", + "box_threat", "press_trigger", +} +THE_SIX_ATTRIBUTES = { + "pace", "passing_range", "carrying_1v1", + "positional_discipline", "aerial_physical", "pressing_engine", +} +# The archetype codes doc 06 section 2.6's combination rules name directly. +# If any of these stops existing, those rules reference nothing. +DOC06_NAMED_CODES = [ + "six_metronome", "six_line_breaker", "six_destroyer", + "eight_half_space_creator", "eight_box_crasher", "eight_carrier", + "eight_ball_winner", "eight_deep_rotator", "eight_wide_rotator", +] + + +def _load(name: str) -> dict: + return json.loads((SEEDS / name).read_text(encoding="utf-8")) + + +def _items(name: str) -> list[dict]: + return _load(name)["items"] + + +# --------------------------------------------------------------------------- +# Content +# --------------------------------------------------------------------------- + + +def test_every_slot_family_has_at_least_one_archetype() -> None: + """doc 06 section 2.6 lists ten slot families. The ticket seeds all ten, + not only the eight it works out in full.""" + families = {item["slot_family"] for item in _items(ARCHETYPES_FILE)} + assert families == set(SLOT_FAMILIES), f"missing: {set(SLOT_FAMILIES) - families}" + + +def test_the_eight_family_carries_every_archetype_doc06_works_out_in_full() -> None: + eights = {i["code"] for i in _items(ARCHETYPES_FILE) if i["slot_family"] == "eight"} + assert eights == { + "eight_half_space_creator", + "eight_box_crasher", + "eight_carrier", + "eight_ball_winner", + "eight_deep_rotator", + "eight_wide_rotator", + } + + +def test_every_archetype_named_by_doc06s_combination_rules_exists() -> None: + codes = {i["code"] for i in _items(ARCHETYPES_FILE)} + for code in DOC06_NAMED_CODES: + assert code in codes, f"doc 06 section 2.6 names '{code}' but no row seeds it" + + +def test_key_attribute_keys_are_two_to_three_of_the_six() -> None: + for item in _items(ARCHETYPES_FILE): + attrs = item["key_attribute_keys"] + assert 2 <= len(attrs) <= 3, f"{item['code']}: {len(attrs)} key attributes" + assert set(attrs) <= THE_SIX_ATTRIBUTES, f"{item['code']}: {set(attrs) - THE_SIX_ATTRIBUTES}" + + +def test_duties_come_from_the_closed_vocabulary_and_every_archetype_has_one() -> None: + for item in _items(ARCHETYPES_FILE): + duties = item["duties_json"] + assert duties, f"{item['code']}: no duty, so the balance checker can never see it" + assert set(duties) <= DUTY_VOCABULARY, f"{item['code']}: {set(duties) - DUTY_VOCABULARY}" + + +def test_every_archetype_states_what_it_needs_around_it() -> None: + """The ticket's standard: no empty strings, no filler like 'good + players'. A one-line requirement is the point of the field.""" + for item in _items(ARCHETYPES_FILE): + needs = item["needs_around_it"] + assert needs and len(needs.split()) >= 5, f"{item['code']}: needs_around_it is filler" + + +def test_every_exemplar_note_carries_the_standing_disclaimer() -> None: + """seeds/roles.json's convention: 'Not a licence: names are editorial + reference points only.' Identities and reference points curate, they + never lock. exemplar_note is nullable, and a null claims nothing.""" + for item in _items(ARCHETYPES_FILE): + note = item["exemplar_note"] + if note is None: + continue + assert note.endswith("Not a licence: names are editorial reference points only."), item["code"] + + +def test_every_combination_states_a_cost() -> None: + """A library that lists only benefits is marketing, not coaching.""" + for item in _items(COMBINATIONS_FILE): + costs = item["what_it_costs"] + assert costs and len(costs.split()) >= 5, f"{item['code']}: what_it_costs is empty or thin" + + +def test_every_combination_slot_resolves_to_a_real_archetype_of_that_family() -> None: + families = {i["code"]: i["slot_family"] for i in _items(ARCHETYPES_FILE)} + for item in _items(COMBINATIONS_FILE): + for slot in item["slots_json"]: + code = slot["archetype_code"] + assert code in families, f"{item['code']}: unknown archetype '{code}'" + assert families[code] == slot["slot_family"], f"{item['code']}: family mismatch on '{code}'" + + +def test_unit_balance_copy_reads_as_a_check_not_an_error() -> None: + """doc 06 is explicit that a coach may want the 'imbalanced' + combination on purpose, so the vocabulary of failure is banned.""" + banned = ["invalid", "illegal", "wrong", "forbidden", "not allowed", "error"] + for item in _items(RULES_FILE): + lowered = item["warning_copy"].lower() + for word in banned: + assert word not in lowered, f"{item['code']}: warning_copy says '{word}'" + assert "check" in lowered, f"{item['code']}: warning_copy never asks the coach to check anything" + + +def test_identity_copy_bans_apply_to_archetype_and_combination_copy_too() -> None: + """CLAUDE.md rule 6 / doc 03 section 7.6: 'correct', 'right way', and + 'off-identity' never appear. exemplar_note names real players, so this + copy sits under the same 'curate, never lock' rule as identity copy.""" + banned = ["correct", "right way", "off-identity"] + for fname in (ARCHETYPES_FILE, COMBINATIONS_FILE, RULES_FILE): + blob = (SEEDS / fname).read_text(encoding="utf-8").lower() + for phrase in banned: + assert phrase not in blob, f"{fname}: contains '{phrase}'" + + +def test_no_em_dash_in_the_three_tactics_seed_files() -> None: + """CLAUDE.md rule 3. The character is built with chr() rather than + typed, so this test does not fail scripts/check_copy.py's own scan.""" + em_dash = chr(0x2014) + for fname in (ARCHETYPES_FILE, COMBINATIONS_FILE, RULES_FILE): + assert em_dash not in (SEEDS / fname).read_text(encoding="utf-8"), fname + + +def test_every_tactics_row_carries_source_ref_and_content_version() -> None: + for fname in (ARCHETYPES_FILE, COMBINATIONS_FILE, RULES_FILE): + data = _load(fname) + for item in data["items"]: + assert item["source_ref"].startswith("doc06:"), f"{fname} {item['code']}" + assert item["content_version"] == data["content_version"], f"{fname} {item['code']}" + + +def test_balance_rule_counts_match_their_rule_kind() -> None: + for item in _items(RULES_FILE): + if item["rule_kind"] == "requires_duty": + assert isinstance(item["min_count"], int), item["code"] + assert item["duty"] in DUTY_VOCABULARY, item["code"] + else: + assert isinstance(item["max_count"], int), item["code"] + if item["rule_kind"] == "max_same_archetype": + assert item["duty"] is None, item["code"] + + +def test_named_combinations_that_trip_a_rule_say_so_in_their_cost_line() -> None: + """The seeded combinations are doc 06's own 'named good combinations', + and three of them deliberately trip a balance rule (the gegenpress trio + has no tempo setter; the rotating trio has two; the carry-and-screen + pivot has none). That is coherent only if the combination's own cost + line admits it, so this test pins the pairing rather than letting the + two halves of the library drift apart.""" + archetypes = {i["code"]: i for i in _items(ARCHETYPES_FILE)} + rules = _items(RULES_FILE) + + def fired(combination: dict) -> list[str]: + codes = [s["archetype_code"] for s in combination["slots_json"]] + duties: dict[str, int] = {} + for code in codes: + for duty in archetypes[code]["duties_json"]: + duties[duty] = duties.get(duty, 0) + 1 + most_repeated = max(codes.count(c) for c in codes) + hits = [] + for rule in rules: + if rule["unit"] != combination["unit"]: + continue + kind = rule["rule_kind"] + if kind == "requires_duty" and duties.get(rule["duty"], 0) < rule["min_count"]: + hits.append(rule["code"]) + elif kind == "max_duty" and duties.get(rule["duty"], 0) > rule["max_count"]: + hits.append(rule["code"]) + elif kind == "max_same_archetype" and most_repeated > rule["max_count"]: + hits.append(rule["code"]) + return hits + + expected = { + "mt_destroyer_carrier_winner": ["mt_needs_a_tempo_setter"], + "mt_breaker_rotator_crasher": ["mt_one_tempo_setter"], + "dp_breaker_shuttler": ["dp_needs_rest_defence"], + "dp_carrier_destroyer": ["dp_needs_a_controller"], + } + for combination in _items(COMBINATIONS_FILE): + hits = fired(combination) + assert hits == expected.get(combination["code"], []), ( + f"{combination['code']} trips {hits}, which the seed's cost line does not account for" + ) + if hits: + assert combination["what_it_costs"], combination["code"] + + +# --------------------------------------------------------------------------- +# The validator actually rejects each violation +# --------------------------------------------------------------------------- + +_module_counter = itertools.count() + + +def _fresh_validator(seeds_dir: pathlib.Path): + """A brand new module object per call: scripts/validate_seeds.py keeps + its findings in a module-level `errors` list, so a reused import would + leak one test's failures into the next.""" + name = f"pop_validate_seeds_{next(_module_counter)}" + spec = importlib.util.spec_from_file_location(name, VALIDATOR) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + module.SEEDS = seeds_dir + return module + + +def _run_validator(tmp_path: pathlib.Path, mutate: Callable[[pathlib.Path], None]) -> tuple[int, str]: + seeds_copy = tmp_path / "seeds" + shutil.copytree(SEEDS, seeds_copy) + mutate(seeds_copy) + module = _fresh_validator(seeds_copy) + buffer = io.StringIO() + with contextlib.redirect_stdout(buffer): + code = module.main() + return code, buffer.getvalue() + + +def _edit(seeds_dir: pathlib.Path, fname: str, index: int, **fields) -> None: + path = seeds_dir / fname + data = json.loads(path.read_text(encoding="utf-8")) + data["items"][index].update(fields) + path.write_text(json.dumps(data, indent=2), encoding="utf-8") + + +def test_the_negative_harness_passes_on_unmutated_seeds(tmp_path: pathlib.Path) -> None: + """Guard rail for every test below: prove the harness reports success on + untouched seeds, so a rejection below is the rule firing and not the + copy-into-tmp mechanism failing.""" + code, out = _run_validator(tmp_path, lambda _: None) + assert code == 0, out + assert "all checks passed" in out + + +def test_validator_rejects_a_duty_outside_the_closed_vocabulary(tmp_path: pathlib.Path) -> None: + code, out = _run_validator( + tmp_path, + lambda seeds: _edit(seeds, ARCHETYPES_FILE, 0, duties_json=["tempo", "dictates_play"]), + ) + assert code == 1 + assert "duties_json 'dictates_play' not in the closed duty vocabulary" in out + + +def test_validator_rejects_a_single_key_attribute(tmp_path: pathlib.Path) -> None: + code, out = _run_validator( + tmp_path, + lambda seeds: _edit(seeds, ARCHETYPES_FILE, 0, key_attribute_keys=["pace"]), + ) + assert code == 1 + assert "key_attribute_keys has 1 entries" in out + + +def test_validator_rejects_four_key_attributes(tmp_path: pathlib.Path) -> None: + code, out = _run_validator( + tmp_path, + lambda seeds: _edit( + seeds, + ARCHETYPES_FILE, + 0, + key_attribute_keys=["pace", "passing_range", "carrying_1v1", "aerial_physical"], + ), + ) + assert code == 1 + assert "key_attribute_keys has 4 entries" in out + + +def test_validator_rejects_an_attribute_outside_the_six(tmp_path: pathlib.Path) -> None: + code, out = _run_validator( + tmp_path, + lambda seeds: _edit(seeds, ARCHETYPES_FILE, 0, key_attribute_keys=["pace", "stamina"]), + ) + assert code == 1 + assert "key_attribute_keys 'stamina' is not one of the six attributes" in out + + +def test_validator_rejects_an_empty_what_it_costs(tmp_path: pathlib.Path) -> None: + code, out = _run_validator( + tmp_path, + lambda seeds: _edit(seeds, COMBINATIONS_FILE, 0, what_it_costs=""), + ) + assert code == 1 + assert "missing required field 'what_it_costs'" in out + + +def test_validator_rejects_an_unresolvable_archetype_code(tmp_path: pathlib.Path) -> None: + def mutate(seeds: pathlib.Path) -> None: + path = seeds / COMBINATIONS_FILE + data = json.loads(path.read_text(encoding="utf-8")) + data["items"][0]["slots_json"][0]["archetype_code"] = "six_nonexistent" + path.write_text(json.dumps(data, indent=2), encoding="utf-8") + + code, out = _run_validator(tmp_path, mutate) + assert code == 1 + assert "archetype_code 'six_nonexistent' does not exist" in out + + +def test_validator_rejects_a_slot_family_that_cannot_appear_in_that_unit(tmp_path: pathlib.Path) -> None: + def mutate(seeds: pathlib.Path) -> None: + path = seeds / COMBINATIONS_FILE + data = json.loads(path.read_text(encoding="utf-8")) + data["items"][0]["slots_json"][0] = {"slot_family": "nine", "archetype_code": "nine_target"} + path.write_text(json.dumps(data, indent=2), encoding="utf-8") + + code, out = _run_validator(tmp_path, mutate) + assert code == 1 + assert "cannot appear in unit 'midfield_three'" in out + + +def test_validator_rejects_warning_copy_that_reads_as_an_error(tmp_path: pathlib.Path) -> None: + code, out = _run_validator( + tmp_path, + lambda seeds: _edit( + seeds, RULES_FILE, 0, warning_copy="This midfield three is invalid and must be changed." + ), + ) + assert code == 1 + assert "warning_copy uses 'invalid'" in out + + +def test_validator_rejects_warning_copy_that_reads_as_a_verdict(tmp_path: pathlib.Path) -> None: + code, out = _run_validator( + tmp_path, + lambda seeds: _edit( + seeds, RULES_FILE, 0, warning_copy="This midfield three has nobody setting the tempo." + ), + ) + assert code == 1 + assert "never asks the coach to check anything" in out + + +def test_validator_rejects_an_unknown_slot_family(tmp_path: pathlib.Path) -> None: + code, out = _run_validator( + tmp_path, + lambda seeds: _edit(seeds, ARCHETYPES_FILE, 0, slot_family="libero"), + ) + assert code == 1 + assert "slot_family 'libero' not in" in out + + +def test_validator_rejects_filler_in_needs_around_it(tmp_path: pathlib.Path) -> None: + code, out = _run_validator( + tmp_path, + lambda seeds: _edit(seeds, ARCHETYPES_FILE, 0, needs_around_it="Good players."), + ) + assert code == 1 + assert "needs_around_it is 2 words" in out + + +# --------------------------------------------------------------------------- +# Loader +# --------------------------------------------------------------------------- + + +def _import_seed_module(): + spec = importlib.util.spec_from_file_location( + "pop_seed_script_tactics", REPO_ROOT / "scripts" / "seed.py" + ) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def test_seed_loader_loads_the_three_tactics_tables_and_stays_idempotent() -> None: + """The DoD line: proven by a test, not asserted. Runs the real loader + against the test database (conftest gives each test session a fresh + one), counts rows against the seed files, then runs it a second time to + confirm the upsert updates in place rather than duplicating.""" + seed = _import_seed_module() + + from app.db import SessionLocal + from app.models import ArchetypeCombination, PositionArchetype, UnitBalanceRule + + assert seed.main() == 0 + + session = SessionLocal() + try: + first = { + "position_archetypes": session.query(PositionArchetype).count(), + "archetype_combinations": session.query(ArchetypeCombination).count(), + "unit_balance_rules": session.query(UnitBalanceRule).count(), + } + # One round trip through the database, not just through the file. + metronome = session.get(PositionArchetype, "six_metronome") + assert metronome is not None + assert metronome.slot_family == "six" + assert set(metronome.duties_json) == {"tempo", "rest_defence"} + assert metronome.key_attribute_keys == ["passing_range", "positional_discipline"] + + combination = session.get(ArchetypeCombination, "mt_metronome_creator_crasher") + assert combination is not None + assert combination.unit == "midfield_three" + assert combination.what_it_costs + assert len(combination.slots_json) == 3 + + rule = session.get(UnitBalanceRule, "mt_needs_a_tempo_setter") + assert rule is not None + assert rule.rule_kind == "requires_duty" + assert rule.duty == "tempo" + assert rule.min_count == 1 + assert rule.severity == "warning" + finally: + session.close() + + assert first == { + "position_archetypes": len(_items(ARCHETYPES_FILE)), + "archetype_combinations": len(_items(COMBINATIONS_FILE)), + "unit_balance_rules": len(_items(RULES_FILE)), + } + + teams_before = _count_teams() + assert seed.main() == 0 + + session = SessionLocal() + try: + second = { + "position_archetypes": session.query(PositionArchetype).count(), + "archetype_combinations": session.query(ArchetypeCombination).count(), + "unit_balance_rules": session.query(UnitBalanceRule).count(), + } + finally: + session.close() + + assert second == first, "re-running the seeder must upsert, not duplicate" + assert _count_teams() == teams_before, "the seeder must never touch team-scoped tables" + + +def _count_teams() -> int: + from app.db import SessionLocal + from app.models import Team + + session = SessionLocal() + try: + return session.query(Team).count() + finally: + session.close() + + +@pytest.mark.parametrize("fname", [ARCHETYPES_FILE, COMBINATIONS_FILE, RULES_FILE]) +def test_seed_loader_knows_about_every_new_file(fname: str) -> None: + """A seed file the loader does not list is a file that silently never + reaches the database. scripts/seed.py raises on an unknown table, so + the real risk is the reverse: a file present but absent from + LOAD_ORDER, which falls through to the unordered tail.""" + seed = _import_seed_module() + table = _load(fname)["table"] + assert table in seed.TABLE_CONFIG + assert fname in seed.LOAD_ORDER diff --git a/docs/agent/BACKLOG.md b/docs/agent/BACKLOG.md index 5793d36..bb5a1fa 100644 --- a/docs/agent/BACKLOG.md +++ b/docs/agent/BACKLOG.md @@ -42,15 +42,47 @@ Dispatch rule for this epic: give a subagent its ticket row plus **only** the do |---|---|---|---|---|---|---|---| | 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-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 | done | +| 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 | done | +| T-111 | Alembic 0007: delete the orphan rondo_zones row zone_key='counterpress' left behind by T-103's rename to counterpress_ring. Upsert-only seeder never removes it, so a persistent-disk deploy would render seven zones on the 4-3-3 | 2.3 | platform | sonnet | T-103 | T-105 | done | | 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 | +| 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 | done | +| 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 | done (renders 5 of 6 zones, see T-112) | +| T-112 | Counterpress ring, currently unrendered so the Rondo Map ships 5 of the 6 zones doc 06 section 0 approved. Doc 06 section 2.3 already specifies the no-ball fallback (centroid of our three most advanced), which T-106 never saw because its row named only 5.1/5.2/5.4. Add canonical_rondo/zone_kind/radius to RondoZoneOut and the formations.py mapping, draw the ring as a circle, delete the splitRondoName ratio workaround | 0, 2.3, 5.1 | screens | opus | T-106 | T-107 | done | +| 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 | done | +| T-113 | Persist the personnel panel to team_formations and team_formation_slots. Doc 06 section 3.2 designed those tables and T-108 shipped the API, but no ticket ever wired the UI, so a coach's slot and archetype picks vanish on navigation. Coach-only writes, reads open to players per the founder decision 2026-08-07 | 3.2, 5.3 | screens | sonnet | T-107 | none | 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 | done (unit balance deferred, see T-110) | +| 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, wire POP_WEB_PORT/POP_API_PORT into make verify so parallel worktrees stop colliding, fix the stale `counterpress` value in RondoZone.zone_key's inline comment (models/formations.py) | 6 | verifier | sonnet | T-106, T-107, T-108, T-110 | none | done (audit found 3 unmet DoD points, see T-114/T-115) | +| T-114 | Reference systems have no UI surface. Ten seeded rows are unreachable: IdentityPage SEGMENTS omits kind=reference_system, and FormationsPage fetches phase `reference_code` but never renders the identity. Also scripts/seed.py loads only `items`, so the file-level disclaimer note in identities_reference_systems.json never reaches the DB, leaving doc 06 section 6 point 6 unfalsifiable | 2.5, 6 | screens | opus | T-109 | T-115 | todo | +| T-115 | DoD cleanup from the T-109 audit: zone-count fixture per formation PAIR (15, doc 06 section 6 point 5 currently has one per formation, 6); a scoped.py helper for parent-scoped bulk delete so update_team_formation's raw delete stops bypassing the layer; validate that position_archetypes exemplar_note ends with the standing disclaimer, as rotation_systems already does; update the hardcoded 5173/8000 in README, verify-ui SKILL.md and seed_demo.py now that ports derive per worktree | 6 | verifier | sonnet | T-109 | T-114 | todo | +| T-110 | Slot-to-unit crosswalk (spec gap found in T-108): map each formation's eleven slots to a slot_family, and slot_families to the seven unit_balance_rules units, so unit balance can be evaluated per formation. Then the coach-only balance evaluation on the API with its 403 test | 2.6, 3.1, 5.3 | content-seeder | opus | T-102, T-103 | none | done (5-4-1 evaluates back line only, founder call open) | 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. + +--- + +## Epic T-070: Brand refresh (founder commission 2026-08-07) + +Source of truth: the founder directive of 2026-08-07 plus the logo asset. **The directive supersedes the +design-handoff token table**, which still specifies a gold accent on the default theme and is now stale; +amending it is part of T-071. Everything else in the design README (layout, interactions, permissions) +still wins. Land this epic before any remaining T-100 ticket: it rewrites `tokens.css`, `AppShell`, and +`frontend/src/board/` styling, which T-113 and T-114 also touch. + +Brand constants sampled from the asset: brand red `#C81C1C`, shield navy `#16304F`, shield gold `#C9A227`, +grass green `#3B7A44`. + +| ID | Title | Source | Agent | Model | Deps | Parallel-safe with | Status | +|---|---|---|---|---|---|---|---| +| T-070 | Logo integration: derivative assets in `frontend/public/` (transparent-background shield mark, full lockup, favicon set), sign-in lockup, nav-rail shield mark replacing `app-brand-dot`, favicon + tab title. Keeps the App.test.tsx "Patterns of Play" heading assertion passing | founder | screens | sonnet | none | T-071 | done | +| T-071 | Palette: `pitch` default becomes the red brand theme; `dark` and `board` restyled to the brand. Board decoupled from chrome tokens (real `--pitch-*`, `--team-*`, `--lane-*` tokens) so a red accent cannot turn the pitch red or collapse home/away. AA contrast. Amends the design README token table and the `tokens.css` header comment | founder | screens | opus | none | T-070 | done | +| T-072 | QA sweep: every main screen x both viewports x all three themes. Fixes styling and sizing regressions including ones that predate this epic. Screenshot review, not a green test run | founder | verifier | opus | T-070, T-071 | none | todo | +| T-074 | README refresh: lead with the logo, recapture `docs/screenshots/` against the rebranded UI, show off the design | founder | screens | sonnet | T-072 | none | todo | + +Sequencing: (T-070 ∥ T-071) → T-072 → T-074 → ship. +T-070 and T-071 are parallel-safe on a strict file split: T-070 owns `frontend/public/`, `index.html`, +`AuthForms.tsx`, `auth.css`, `AppShell.tsx`, `AppShell.css`; T-071 owns `styles/tokens.css`, +`frontend/src/board/`, `pages/*.css`, and the design-handoff README. Neither crosses into the other's set. +T-071 is opus: the token architecture and the contrast work are the hard part of this epic, not the asset work. diff --git a/docs/source/design-handoff/README.md b/docs/source/design-handoff/README.md index 2d83eb0..4ed8b7e 100644 --- a/docs/source/design-handoff/README.md +++ b/docs/source/design-handoff/README.md @@ -5,22 +5,55 @@ Top controls switch **Desktop / Phone** frames and the three themes. Everything ## Design tokens -Three themes share one CSS-variable token set (`html[data-theme]`), so screens are theme-agnostic. +> **Source of truth: the founder palette directive of 2026-08-07 (ticket T-071).** It replaces the original gold-accent table and the two rules that sat under it, and it supersedes the values baked into `pop-mvp-mockups.html` and the colours in the numbered PNGs (which still show the old gold chrome). Everything else in this document, layout, interactions, permissions, the board's visual language, is unchanged and still wins. + +Three themes share one CSS-variable token set (`html[data-theme]`), so screens are theme-agnostic. The live values are `frontend/src/styles/tokens.css`; `scripts/check_palette.py` fails the build if this table and that file drift apart on the rules below. + +Brand constants, sampled from the logo: brand red `#C81C1C`, shield navy `#16304F`, shield gold `#C9A227`, grass green `#3B7A44`. + +### Two layers, and they must not be mixed + +**1. Chrome tokens.** The application shell. + +| Token | Pitch (default) | Dark | Board (light) | +|---|---|---|---| +| `--bg` app background | `#081422` navy | `#121417` | `#FAFAF6` | +| `--sidebar-bg` nav / drawers | `#0A1A2B` | `#17191D` | `#F0F1EB` | +| `--surface` cards, toolbars | `#0F2338` | `#1D2025` | `#FFFFFF` | +| `--text-primary` / `--text-secondary` | `#F5F3E9` / `#A6BCD4` | `#ECEDEE` / `#9AA1AA` | `#1B2420` / `#58635B` | +| `--accent` **interactive brand red** | `#EF5350` | `#F4635A` | `#C81C1C` | +| `--accent-ink` on a filled accent | `#2A0605` | `#2A0605` | `#FFFFFF` | +| `--glow` accent halo | `#FF8079` | `#FF8B80` | `#E24A44` | +| `--warn` **advisory shield gold** | `#C9A227` | `#D2AB2E` | `#8A6A08` | +| `--on-warn` / `--bg-warn` / `--text-warn` | `#241A00` / gold 16% / `#E9C651` | `#201700` / gold 16% / `#E6C24C` | `#FFFFFF` / `#FAF2DA` / `#7A5D06` | +| `--red` **failure crimson** | `#CF3560` | `#DE3F63` | `#A11331` | +| `--bg-red` / `--text-red` | crimson 16% / `#FF8FA8` | crimson 16% / `#FF8DA4` | `#FBE9EE` / `#8F1130` | + +The dark themes carry a brightened brand red because no deep red can clear 4.5:1 as text on a dark ground; the light theme carries the logo's own `#C81C1C`. + +**2. Board tokens.** A football pitch, defined independently in every theme. Nothing in `frontend/src/board/`, and no board surface anywhere else (mini thumbnails, keystone rings, the positional grid overlay), may read a chrome token for a football meaning. | Token | Pitch (default) | Dark | Board (light) | |---|---|---|---| -| `--bg` app background | `#0F3C2C` deep pitch green | `#14161A` | `#FAFAF6` | -| `--bg-stripe` / `--bg-stripe-alt` turf | `#3B7A57` / `#336A4B` mown stripes | flat dark | flat light | -| `--sidebar-bg` nav / drawers | `#0B2F22` | `#191C21` | `#F1F2EC` | -| `--surface` cards, toolbars | `#1B4B39` | `#1D2025` | `#FFFFFF` | -| `--accent` interactive gold | `#E8B923` (trophy gold) | `#4FA8FF` | `#2D6A4F` | -| `--glow` ball / suggestion | `#FFD65A` | `#7CC1FF` | `#2D6A4F` | -| `--red` maple status red | `#E23D42` | `#E5484D` | `#C81E2C` | +| `--pitch-turf` / `--pitch-stripe` mown turf | `#2D6434` / `#28592E` | `#1F4A28` / `#1B4223` | `#D7E6D4` / `#CDDECA` | +| `--pitch-line` markings | `#DCEADE` | `#C6DCCA` | `#56785C` | +| `--token-face` the disc behind a token | `#0B1C11` | `#071009` | `#FFFFFF` | +| `--team-home` / `--team-away` | `#EFC63F` / `#FF8A8C` | `#E9BF46` / `#FA8285` | `#7A5D06` / `#A5151C` | +| `--ball` | `#FFE27A` | `#F8DD7B` | `#8F6F0A` | +| `--lane-suggested` / `--lane-confirmed` / `--lane-glow` | `#E8B923` / `#FFD65A` / `#FFE9A0` | `#DDB02A` / `#F6CF5E` / `#FAE5A2` | `#8F6F0A` / `#6F5405` / `#B8951F` | +| `--lane-blocked` / `--intercept` / `--mark` | `#FF8A8C` | `#FA8285` | `#A5151C` | +| `--zone` / `--keystone` | `#E8B923` / `#FFD65A` | `#DDB02A` / `#F6CF5E` | `#7A5D06` / `#6F5405` | +| `--route-badge` / `--route-badge-ink` | `#FFD65A` / `#33280A` | `#F6CF5E` / `#2E2409` | `#6F5405` / `#FFFFFF` | + +On the light `board` theme the turf is pale, so every mark on it is deep rather than bright: same football language, different value. There is no `--bg-stripe`: the board used to borrow that chrome token as turf, which is exactly what this split exists to prevent. Rules the palette encodes: -- **Gold is the only interactive color** (buttons, active nav, confirmed lanes, ball glow, keystone pulse). -- **Red is status only** — opposition tokens, blocked lanes, marking rings, fit warnings, record state, live/notification badges. Red is never a call to action. -- Type: Oswald (display — titles, numbers, section labels) + Inter (body/UI). +- **The brand red `--accent` is the only interactive colour, and the only red fill**: buttons, active nav, active tabs and tools, focus rings, hover borders, range thumbs. +- **Shield gold `--warn` carries advisories and read-only emphasis**: fit warnings, unit-balance clash notes, the SENT pill, receipts, verdict chips, author stamps, category labels. Nothing gold is clickable. +- **`--red` is failure only**, rendered as text, a 1px outline, or a faint `--bg-red` tint, and never as a fill on a control. It is held at a distinctly cooler crimson from the scarlet accent, so the two reds never read as one colour. +- **The board keeps the football language below**, in all three themes: green turf, gold "the pass is on", red "blocked / opposition / marking". The chrome went red for the brand; the pitch did not. **Changing `--accent` must not be able to change what the pitch or a lane looks like**, which `scripts/check_palette.py` and `e2e/palette.spec.ts` both enforce. +- Contrast: WCAG AA on every pair that carries meaning, 4.5:1 for text and 3:1 for graphics and borders, computed in `scripts/check_palette.py` rather than eyeballed. +- Type: Oswald (display, titles, numbers, section labels) + Inter (body/UI). ## Visual language on the board diff --git a/e2e/counterpress-ring.spec.ts b/e2e/counterpress-ring.spec.ts new file mode 100644 index 0000000..b5dc46f --- /dev/null +++ b/e2e/counterpress-ring.spec.ts @@ -0,0 +1,409 @@ +// The counterpress ring (T-112, doc 06 sections 0, 2.3, 5.1). Runs under +// both Playwright projects: desktop landscape at 1440x900 and iPhone 13 at +// 390x844, where the board renders PORTRAIT and the meta bar collapses to +// an icon row. +// +// WHY THIS FILE EXISTS. Doc 06 section 0 approved a six zone Rondo Map on +// all six formations as a founder decision. T-106 shipped five, because +// section 5.1 says the ring renders only "when a ball is placed or a phase +// with a defined ball position is active" and this page has neither. It was +// never shown section 2.3, which defines the ring as a circle of radius 18 +// centred on the ball OR, when no ball is placed, on the centroid of our +// three most advanced players. That fallback is unconditional, so the ring +// is always renderable and section 2.3 resolves the contradiction. +// +// Covers this ticket's DoD lines: +// the Rondo Map shows six zones on all six formations; +// the ring renders as a circle and never as its seeded polygon; +// the ring MOVES when the phase changes, which is the teaching point; +// its chip shows a seeded ratio with opposition off and a computed one +// with opposition on, and the two are never mistakable; +// the ring does not steal taps from the polygon zones underneath it. + +import { test, expect, assertCleanPage, registerCoach } from "./fixtures"; +import type { Page } from "@playwright/test"; + +/** True on the phone project, where doc 06 section 5.4 collapses the meta + * bar to icons and every control opens a bottom sheet. Detected from the + * DOM rather than from the project name, so the test follows the layout + * the app actually chose. */ +async function isPhone(page: Page): Promise { + return (await page.getByTestId("formations-phase-toggle").count()) > 0; +} + +async function openFormations(page: Page) { + await page.getByTestId("nav-formations").click(); + await expect(page.getByTestId("formations-meta-bar")).toContainText("4-3-3"); + await expect(page.locator("[data-token-id]")).toHaveCount(11); +} + +async function selectPhase(page: Page, key: string) { + if (await isPhone(page)) { + await page.getByTestId("formations-phase-toggle").click(); + await expect(page.getByTestId("formations-phase-panel")).toBeVisible(); + } + await page.getByTestId(`formations-phase-${key}`).click(); + if (await page.getByTestId("formations-phase-close").count()) { + await page.getByTestId("formations-phase-close").click(); + } +} + +/** The ring's centre and radius in normalized render space, straight off + * the ellipse's own attributes. The layer's viewBox is 0 0 100 100 with + * preserveAspectRatio="none", so these numbers ARE the render coordinates + * the page computed, with no pixel measurement to go fuzzy on us. */ +async function ringGeometry(page: Page) { + return page.getByTestId("formations-rondo-ring").evaluate((el) => ({ + cx: Number(el.getAttribute("cx")), + cy: Number(el.getAttribute("cy")), + rx: Number(el.getAttribute("rx")), + ry: Number(el.getAttribute("ry")), + })); +} + +function ringChip(page: Page) { + return page.locator('[data-chip-zone="counterpress_ring"]'); +} + +/** + * Tap the ring's LINE. + * + * Deliberately not `locator.click()`: that aims at the element's bounding + * box centre, and the centre of this ellipse is its interior, which is + * exactly the part the page refuses to hit test. Aiming at the middle of + * the box's left edge puts the pointer on the ring itself, which is the + * only place a tap on the ring is meant to land, and is also the assertion + * that the affordance is the line rather than the disc. + */ +async function tapRingLine(page: Page) { + const box = await page.getByTestId("formations-rondo-ring").boundingBox(); + expect(box, "the ring must be on screen to be tapped").not.toBeNull(); + await page.mouse.click(box!.x + 1, box!.y + box!.height / 2); +} + +test.describe("counterpress ring: the sixth zone, ball relative and moving", () => { + test("the ring renders, moves with the phase, and carries seeded then computed ratios", async ({ + page, + issues, + }) => { + await registerCoach(page); + await openFormations(page); + + // ------------------------------------------------------------------ + // DoD: the Rondo Map shows SIX zones. Five polygons plus the ring, + // which is a circle and so is deliberately not a `rondo-zone`. + // ------------------------------------------------------------------ + await page.getByTestId("formations-rondo-toggle").click(); + await expect(page.getByTestId("rondo-zone")).toHaveCount(5); + await expect(page.getByTestId("formations-rondo-ring")).toHaveCount(1); + await expect(page.getByTestId("formations-zone-chip")).toHaveCount(6); + + // The ring is a CIRCLE, never the polygon seeded on the same row. That + // polygon bounds the half of the pitch the ring is coached in. + await expect(page.locator('[data-zone-key="counterpress_ring"]')).toHaveCount(0); + + // Radius 18 model units, off the seeded column, on both axes of the + // normalized render space. Equal rx and ry there is what makes the + // painted shape the same locus pointInCircle counts inside; the board's + // aspect ratio then stretches it, differently per orientation, which is + // why it is an ellipse on screen and correct rather than pretty. + const base = await ringGeometry(page); + expect(base.rx).toBe(18); + expect(base.ry).toBe(18); + expect(base.rx).toBe(base.ry); + + // It sits where the shape is, not on a fixed anchor: the 4-3-3's three + // most advanced average model (80.3, 50), which renders as (80.3, 50) + // landscape and (50, 19.7) portrait once left = y and top = 100 - x are + // applied. Both are the same model point mapped through modelToRender, + // which is the only place this page knows about orientation. + const portrait = await isPhone(page); + if (portrait) { + expect(base.cx).toBeCloseTo(50, 3); + expect(base.cy).toBeCloseTo(100 - 80.3333, 2); + } else { + expect(base.cx).toBeCloseTo(80.3333, 2); + expect(base.cy).toBeCloseTo(50, 3); + } + + // THE PORTRAIT MAPPING, measured rather than assumed. A circle in model + // space is NOT a circle on screen: the model is a 0-100 square that the + // board stretches to 1050x680 landscape and 700x1000 portrait, so the + // painted shape is an ellipse whose axis ratio is the pitch's own. That + // is the correct rendering, because it is the shape pointInCircle + // actually counts inside; a screen-perfect circle would disagree with + // the counting, and would disagree differently in each orientation. + const ringBox = await page.getByTestId("formations-rondo-ring").boundingBox(); + const boardBox = await page.getByTestId("formations-chip-layer").boundingBox(); + expect(ringBox).not.toBeNull(); + expect(boardBox).not.toBeNull(); + // The measured box includes the painted stroke, one half of + // .formations-ring's 2px width on each side. Non-scaling stroke is what + // makes that a flat 2px on both axes rather than something that has to + // be unscaled per axis. + const STROKE_PX = 2; + const drawnW = ringBox!.width - STROKE_PX; + const drawnH = ringBox!.height - STROKE_PX; + // Each axis is 36% (2 x radius 18) of the board on that axis. + expect(Math.abs(drawnW - 0.36 * boardBox!.width)).toBeLessThan(1.5); + expect(Math.abs(drawnH - 0.36 * boardBox!.height)).toBeLessThan(1.5); + // Which means the ring's aspect IS the pitch's aspect, and the ring is + // visibly not a screen circle in either orientation. Landscape is wider + // than tall, portrait taller than wide, from the same model radius. + expect(drawnW / drawnH).toBeCloseTo(boardBox!.width / boardBox!.height, 1); + expect(Math.abs(drawnW - drawnH)).toBeGreaterThan(1); + if (portrait) { + expect(drawnH).toBeGreaterThan(drawnW); + } else { + expect(drawnW).toBeGreaterThan(drawnH); + } + + // ------------------------------------------------------------------ + // DoD: the ring MOVES when the phase changes. Doc 06 section 2.3: + // "It moves. That is the whole teaching point: rest defence is + // relative to the ball, not to the pitch." + // ------------------------------------------------------------------ + await selectPhase(page, "in_possession"); + await expect(page.getByTestId("formations-phase-caption")).toContainText("Trigger:"); + const inPossession = await ringGeometry(page); + // Advancing the front three moves the centre toward their goal, which + // is +x. Landscape reads that on cx, portrait on cy (top = 100 - x), so + // the assertion names both rather than assuming a viewport. + expect( + `${inPossession.cx},${inPossession.cy}`, + "the ring must follow the shape into the next phase" + ).not.toBe(`${base.cx},${base.cy}`); + if (portrait) { + expect(inPossession.cy).toBeLessThan(base.cy); + } else { + expect(inPossession.cx).toBeGreaterThan(base.cx); + } + // The radius never changes: only the centre does. + expect(inPossession.rx).toBe(base.rx); + + // A phase that pulls the front line BACK moves it the other way, so + // this is a ring that tracks the shape rather than one that only ever + // drifts forward. + await selectPhase(page, "out_of_possession"); + const outOfPossession = await ringGeometry(page); + if (portrait) { + expect(outOfPossession.cy).toBeGreaterThan(inPossession.cy); + } else { + expect(outOfPossession.cx).toBeLessThan(inPossession.cx); + } + + // Back to base and the ring comes back to exactly where it started: the + // centre is a pure function of the shape on the board, with no drift. + await selectPhase(page, "base"); + expect(await ringGeometry(page)).toEqual(base); + + // ------------------------------------------------------------------ + // DoD: the chip shows a SEEDED ratio with opposition off. Muted, and + // marked as seeded, exactly like the other five. + // ------------------------------------------------------------------ + const chip = ringChip(page); + await expect(chip).toHaveAttribute("data-source", "seeded"); + await expect(chip).toHaveAttribute("data-chip-kind", "ring"); + // seeds/rondo_zones.json canonical_rondo, straight off the wire now + // that RondoZoneOut carries it. This is the string the page could not + // reach before T-112, and it is NOT the "4v4+3" spelling buried in + // rondo_name: proof the chip reads the column rather than the name. + await expect(chip).toHaveText("4v4 plus 3"); + const seededColor = await chip.evaluate((el) => getComputedStyle(el).color); + + // The ring's card explains that this zone is not a place on the pitch, + // and reaching it is a tap on the ring's LINE. + await tapRingLine(page); + await expect(page.getByTestId("formations-zone-card")).toBeVisible(); + await expect(page.getByTestId("formations-zone-title")).toHaveText("The counterpress ring"); + await expect(page.getByTestId("formations-ring-note")).toContainText("This zone moves"); + await expect(page.getByTestId("formations-ring-note")).toContainText("radius 18"); + await expect(page.getByTestId("formations-zone-fallback")).toContainText( + "not a count of what is on the board" + ); + // Its seeded links are the ring's own, not a neighbour's. + await expect(page.getByTestId("formations-linked-pattern").first()).toBeVisible(); + await page.getByTestId("formations-zone-close").click(); + + // ------------------------------------------------------------------ + // DoD: with opposition ON the chip carries a COMPUTED ratio, coloured + // by verdict, and never looks like the seeded one. + // ------------------------------------------------------------------ + await page.getByTestId("formations-opposition-toggle").click(); + await page.getByTestId("formations-opponent-formation").selectOption("442"); + await page.getByTestId("formations-opposition-close").click(); + + await expect(chip).toHaveAttribute("data-source", "computed"); + await expect(chip).toHaveAttribute("data-verdict", /superiority|parity|inferiority/); + await expect(chip).toHaveText(/^\d+v\d+/); + const computedColor = await chip.evaluate((el) => getComputedStyle(el).color); + expect(computedColor, "a computed ratio must not look like a seeded one").not.toBe(seededColor); + + // The count is a real count of bodies inside the circle, not the + // seeded label wearing a verdict. + await expect(chip).not.toHaveText("4v4 plus 3"); + + // The card follows the chip: a live read, no fallback sentence. + await tapRingLine(page); + await expect(page.getByTestId("formations-zone-fallback")).toHaveCount(0); + await expect(page.getByTestId("formations-zone-read")).toContainText( + /Numerical superiority|Numerical inferiority|Parity/ + ); + await page.getByTestId("formations-zone-close").click(); + + await assertCleanPage(page, issues); + }); + + // ------------------------------------------------------------------ + // The overlap rule. The ring is a ball-relative reading, not a partition + // of the pitch, so it crosses the polygon zones by design. It must not + // swallow their taps: only its LINE is hit tested, and its interior is + // not hit tested at all. + // ------------------------------------------------------------------ + test("the ring overlaps the polygon zones without stealing their taps", async ({ + page, + issues, + }) => { + await registerCoach(page); + await openFormations(page); + await page.getByTestId("formations-rondo-toggle").click(); + await expect(page.getByTestId("formations-rondo-ring")).toBeVisible(); + + // The ring genuinely overlaps: its box crosses the last line's box. + const ringBox = await page.getByTestId("formations-rondo-ring").boundingBox(); + const lastLineBox = await page.locator('[data-zone-key="last_line"]').boundingBox(); + expect(ringBox).not.toBeNull(); + expect(lastLineBox).not.toBeNull(); + const overlaps = + ringBox!.x < lastLineBox!.x + lastLineBox!.width && + lastLineBox!.x < ringBox!.x + ringBox!.width && + ringBox!.y < lastLineBox!.y + lastLineBox!.height && + lastLineBox!.y < ringBox!.y + ringBox!.height; + expect(overlaps, "the ring is expected to cross other zones").toBe(true); + + // A tap at the ring's own CENTRE, which is as deep inside it as a tap + // gets, still reaches the zone underneath rather than the ring. This is + // pointer-events: stroke doing its job: the disc is not a target. + const centre = { + x: ringBox!.x + ringBox!.width / 2, + y: ringBox!.y + ringBox!.height / 2, + }; + const under = await page.evaluate( + ({ x, y }) => { + const el = document.elementFromPoint(x, y); + return { + zoneKey: el?.getAttribute("data-zone-key") ?? null, + isRing: el?.classList.contains("formations-ring-hit") ?? false, + }; + }, + centre + ); + expect(under.isRing, "the ring's interior must not be hit tested").toBe(false); + + // And the zones underneath are still individually tappable, card and + // all, with the ring drawn across them. + for (const key of ["midfield_box", "last_line", "first_line"]) { + await page.locator(`[data-zone-key="${key}"]`).click(); + await expect(page.getByTestId("formations-zone-card")).toHaveCount(1); + await expect(page.getByTestId("formations-zone-title")).not.toBeEmpty(); + // The ring's own note belongs to the ring alone. + await expect(page.getByTestId("formations-ring-note")).toHaveCount(0); + await page.getByTestId("formations-zone-close").click(); + } + + await assertCleanPage(page, issues); + }); + + // ------------------------------------------------------------------ + // Doc 06 section 0: six zones on ALL SIX formations, not just the 4-3-3. + // That is the approved scope line this ticket restores, so it is checked + // on every preset rather than on a sample. + // ------------------------------------------------------------------ + test("every formation carries all six zones, the ring included", async ({ page, issues }) => { + await registerCoach(page); + await openFormations(page); + + for (const code of ["433", "4231", "442", "352", "343", "541"]) { + await page.getByTestId("formations-sheet-handle").click(); + await expect(page.getByTestId("formations-sheet-body")).toBeVisible(); + await page.getByTestId("formations-search").fill(code); + await page.getByTestId("formations-tile").first().click(); + await expect(page.getByTestId("formations-sheet-body")).toHaveCount(0); + + await page.getByTestId("formations-rondo-toggle").click(); + await expect(page.getByTestId("rondo-zone"), code).toHaveCount(5); + await expect(page.getByTestId("formations-rondo-ring"), code).toHaveCount(1); + await expect(page.getByTestId("formations-zone-chip"), code).toHaveCount(6); + // Every one of the six carries a seeded fallback, so no formation + // shows a blank chip where a rondo should be. + for (const c of await page.getByTestId("formations-zone-chip").all()) { + await expect(c).toHaveAttribute("data-source", "seeded"); + await expect(c).not.toBeEmpty(); + } + await page.getByTestId("formations-rondo-active-toggle").click(); + await expect(page.getByTestId("formations-rondo-ring")).toHaveCount(0); + } + + await assertCleanPage(page, issues); + }); + + // ------------------------------------------------------------------ + // Theme check (verify-ui): the ring is a themed surface, so it is drawn + // once per theme. T-071 moved it off the chrome accent: the ring is zone + // language drawn on the pitch, so it reads the BOARD token --zone. That + // is the whole point of the two token layers, because the chrome accent + // is now the brand red and a red ring would read as a warning. + // ------------------------------------------------------------------ + test("the ring reads theme variables in all three themes", async ({ page, issues }) => { + await registerCoach(page); + await openFormations(page); + await page.getByTestId("formations-rondo-toggle").click(); + await expect(page.getByTestId("formations-rondo-ring")).toBeVisible(); + + const seen = new Set(); + for (const theme of ["pitch", "dark", "board"] as const) { + await page.getByTestId(`theme-switch-${theme}`).click(); + await expect(page.locator("html")).toHaveAttribute("data-theme", theme); + + const redRgb = await page.evaluate(() => { + const el = document.createElement("div"); + el.style.color = "var(--red)"; + document.body.appendChild(el); + const rgb = getComputedStyle(el).color; + el.remove(); + return rgb; + }); + const accentRgb = await page.evaluate(() => { + const el = document.createElement("div"); + el.style.color = "var(--accent)"; + document.body.appendChild(el); + const rgb = getComputedStyle(el).color; + el.remove(); + return rgb; + }); + const zoneRgb = await page.evaluate(() => { + const el = document.createElement("div"); + el.style.color = "var(--zone)"; + document.body.appendChild(el); + const rgb = getComputedStyle(el).color; + el.remove(); + return rgb; + }); + + const stroke = await page + .getByTestId("formations-rondo-ring") + .evaluate((el) => getComputedStyle(el).stroke); + // The ring is gold zone language on the pitch: never the status red, + // and never the chrome's interactive accent either (T-071). + expect(stroke).toBe(zoneRgb); + expect(stroke).not.toBe(redRgb); + expect(stroke).not.toBe(accentRgb); + seen.add(stroke); + } + // Three themes, three distinct painted values: proves the ring reads a + // CSS variable rather than a colour baked into the component. + expect(seen.size).toBe(3); + + await assertCleanPage(page, issues); + }); +}); diff --git a/e2e/demo-path.spec.ts b/e2e/demo-path.spec.ts index ce88288..99cd354 100644 --- a/e2e/demo-path.spec.ts +++ b/e2e/demo-path.spec.ts @@ -71,6 +71,27 @@ async function addPlayer( await expect(page.getByTestId("player-save")).toHaveCount(0); } +/** True on the phone project, where the Formations meta bar collapses to + * an icon row and the phase segment lives behind a bottom sheet instead + * of sitting in the bar itself (doc 06 section 5.4). Same detection + * e2e/tactics-lab.spec.ts uses. */ +async function isPhone(page: Page): Promise { + return (await page.getByTestId("formations-phase-toggle").count()) > 0; +} + +/** Selects a Tactics Lab phase, opening the phone's Phase sheet first when + * there is one. */ +async function selectPhase(page: Page, key: string) { + if (await isPhone(page)) { + await tap(page.getByTestId("formations-phase-toggle")); + await expect(page.getByTestId("formations-phase-panel")).toBeVisible(); + } + await tap(page.getByTestId(`formations-phase-${key}`)); + if (await page.getByTestId("formations-phase-close").count()) { + await tap(page.getByTestId("formations-phase-close")); + } +} + /** Drags a token by a pixel offset on whichever orientation is rendering. * The demo narrative's "drags a build-out" is about the gesture and what * it records, not about landing on an exact coordinate, so this stays @@ -145,9 +166,39 @@ test.describe("demo path: the Brief section 6 acceptance narrative", () => { await expect(page.getByTestId("rondo-zone-layer")).toBeVisible(); await page.locator('[data-zone-key="first_line"]').click(); await expect(page.getByTestId("formations-zone-card")).toBeVisible(); - await expect(page.getByTestId("formations-zone-title")).toContainText("4v2"); + // Still the 4v2 rondo, but T-106 moved the ratio out of the card title + // and into the on-board chip, where muted "seeded" styling marks it as + // the coached rondo rather than a live count of what is on the board + // (doc 06 section 5.1). + await expect(page.getByTestId("formations-zone-title")).toContainText("First-line build-up"); + const firstLineChip = page.locator('[data-chip-zone="first_line"]'); + await expect(firstLineChip).toContainText("4v2"); + await expect(firstLineChip).toHaveAttribute("data-source", "seeded"); await tap(page.getByTestId("formations-rondo-active-toggle")); + // --- Tactics Lab (doc 06): the coach morphs to the in-possession + // phase, turns the opposition on, and reads a COMPUTED superiority + // rather than the seeded rondo fallback just closed above. Same + // 4-3-3 against 4-4-2 pair e2e/tactics-lab.spec.ts pins as seeded, + // so the read below carries a coached route rather than an + // inferred one. ------------------------------------------------- + await selectPhase(page, "in_possession"); + await expect(page.getByTestId("formations-phase-caption")).toContainText("Trigger:"); + await expect(page.locator("[data-token-id]")).toHaveCount(11); + + await tap(page.getByTestId("formations-opposition-toggle")); + await expect(page.getByTestId("formations-opposition-panel")).toBeVisible(); + await page.getByTestId("formations-opponent-formation").selectOption("442"); + await expect(page.getByTestId("formations-opponent-phase")).not.toHaveValue(""); + await page.getByTestId("formations-opponent-phase").selectOption({ index: 1 }); + await expect(page.locator('[data-token-side="away"]')).toHaveCount(11); + + // The computed read: a named superiority, not the 4v2 rondo label. + await expect(page.getByTestId("formations-read")).toBeVisible(); + await expect(page.getByTestId("formations-read-unseeded")).toHaveCount(0); + await expect(page.getByTestId("formations-read-spare")).toContainText(/superiority/i); + await tap(page.getByTestId("formations-opposition-close")); + // --- "opens Patterns, searches 'third man', plays A5 on the board" ---- await tap(page.getByTestId("nav-patterns")); const patternsHandle = page.getByTestId("patterns-sheet-handle"); diff --git a/e2e/formations.spec.ts b/e2e/formations.spec.ts index 5ac29b0..203124f 100644 --- a/e2e/formations.spec.ts +++ b/e2e/formations.spec.ts @@ -74,16 +74,23 @@ test.describe("formations: board-first shape, keystone keycards, details, rondo await expect(page.getByTestId("formations-details-panel")).toHaveCount(0); // --- 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) --- + // linked patterns (Brief step 18 DoD; seeds/rondo_zones.json). + // Six zones drawn as five polygons plus one circle: T-112 restored the + // counterpress ring, which doc 06 section 2.3 defines as a circle + // around the ball with an unconditional no-ball fallback centre. Its + // seeded polygon only BOUNDS where that circle may sit (half the + // pitch), so it is still never drawn as a zone. + // The zone TITLE is the name alone: T-106 moved the ratio out of it so + // a seeded ratio can never be read as a computed one. --- await page.getByTestId("formations-rondo-toggle").click(); await expect(page.getByTestId("formations-rondo-active-toggle")).toBeVisible(); - await expect(page.getByTestId("rondo-zone")).toHaveCount(6); + await expect(page.getByTestId("rondo-zone")).toHaveCount(5); + await expect(page.getByTestId("formations-rondo-ring")).toHaveCount(1); + await expect(page.locator('[data-zone-key="counterpress_ring"]')).toHaveCount(0); await page.locator('[data-zone-key="midfield_box"]').click(); await expect(page.getByTestId("formations-zone-card")).toBeVisible(); - await expect(page.getByTestId("formations-zone-title")).toHaveText("5v3 (the midfield box)"); + await expect(page.getByTestId("formations-zone-title")).toHaveText("The midfield box"); await expect(page.getByTestId("formations-zone-teaches")).toContainText("split-pass and pause logic"); const linkedPatterns = page.getByTestId("formations-linked-pattern"); await expect(linkedPatterns).toHaveCount(2); @@ -93,7 +100,7 @@ test.describe("formations: board-first shape, keystone keycards, details, rondo // Switching zones swaps the card, not stacks it. await page.locator('[data-zone-key="last_line"]').click(); await expect(page.getByTestId("formations-zone-card")).toHaveCount(1); - await expect(page.getByTestId("formations-zone-title")).toHaveText("2v2 (+1 keeper) (the last line)"); + await expect(page.getByTestId("formations-zone-title")).toHaveText("The last line"); // Exiting rondo mode restores the normal meta bar (Details/Rondo map). await page.getByTestId("formations-rondo-active-toggle").click(); @@ -109,9 +116,23 @@ test.describe("formations: board-first shape, keystone keycards, details, rondo await expect(page.getByTestId("formations-sheet-body")).toHaveCount(0); await expect(page.getByTestId("formations-meta-bar")).toContainText("3-4-3"); - // 3-4-3 has no seeded rondo map: the toggle stays present but disabled - // (do not invent a rondo map beyond what seeds/rondo_zones.json carries). - await expect(page.getByTestId("formations-rondo-toggle")).toBeDisabled(); + // T-103 seeded the rondo map on all six formations (doc 06 section 2.3), + // so the 3-4-3 now carries its own zones with its own polygons rather + // than leaving the toggle disabled. + await expect(page.getByTestId("formations-rondo-toggle")).toBeEnabled(); + await page.getByTestId("formations-rondo-toggle").click(); + await expect(page.getByTestId("rondo-zone")).toHaveCount(5); + // Six zones on every formation, not only the 4-3-3 (doc 06 section 0). + await expect(page.getByTestId("formations-rondo-ring")).toHaveCount(1); + // The 3-4-3's own polygon, not the 4-3-3's: a back three's zones are + // geometrically different from a back four's (doc 06 section 2.3). + await page.locator('[data-zone-key="midfield_box"]').click(); + await expect(page.getByTestId("formations-zone-title")).toHaveText("The midfield box"); + await expect(page.getByTestId("formations-zone-teaches")).toContainText( + "Two central midfielders holding the middle", + ); + await page.getByTestId("formations-rondo-active-toggle").click(); + await expect(page.getByTestId("rondo-zone")).toHaveCount(0); // Its own keystones still tap to their own keycards. await page.locator('[data-token-id="cm_l"]').click(); @@ -121,7 +142,7 @@ test.describe("formations: board-first shape, keystone keycards, details, rondo }); }); -test.describe("formations: matches across all three themes, gold-only interactive, red never a CTA", () => { +test.describe("formations: matches across all three themes, brand red interactive, gold for status", () => { test("keystone pulse, details, and rondo controls are theme-driven, never red", async ({ page, issues }) => { await registerCoach(page); await page.getByTestId("nav-formations").click(); diff --git a/e2e/identity.spec.ts b/e2e/identity.spec.ts index 30635c7..7f2bc60 100644 --- a/e2e/identity.spec.ts +++ b/e2e/identity.spec.ts @@ -262,7 +262,7 @@ test.describe("identity: five-part Section 6 template, pass-risk, cult corner", }); }); -test.describe("identity: matches across all three themes, gold-only interactive, red never a CTA", () => { +test.describe("identity: matches across all three themes, brand red interactive, gold for status", () => { test("segments, Details, and the pass-risk status colors are theme-driven", async ({ page, issues, diff --git a/e2e/palette.spec.ts b/e2e/palette.spec.ts new file mode 100644 index 0000000..1443e73 --- /dev/null +++ b/e2e/palette.spec.ts @@ -0,0 +1,259 @@ +// Brand palette journey (T-071). Runs under both Playwright projects +// (iPhone 13 portrait, desktop 1440x900) per playwright.config.ts, so every +// assertion below is made twice, once per viewport, with the board rendering +// portrait on the phone. +// +// Covers the Screens DoD line (Brief section 5) as amended by the founder +// palette directive of 2026-08-07: +// "Each page matches its PNGs across the three themes on desktop and phone +// frames; gold is the only interactive color; red never appears as a call +// to action." +// The directive supersedes the second half of that line: the brand red is now +// the ONLY interactive colour, shield gold (--warn) carries advisories and +// read-only status, and the status red never fills a control. What survives +// unchanged is the pitch: green turf, gold "the pass is on", red "blocked". +// +// And it covers the invariant the whole ticket exists for: changing --accent +// must not be able to change what the pitch or a lane looks like. Test one +// proves that in a real browser by overriding the chrome accent at runtime. +// scripts/check_palette.py proves the same split statically. + +import { test, expect, assertCleanPage, registerCoach, registerPlayer } from "./fixtures"; +import type { Locator, Page } from "@playwright/test"; + +const THEMES = ["pitch", "dark", "board"] as const; + +const VB = { + landscape: { width: 1050, height: 680 }, + portrait: { width: 700, height: 1000 }, +} as const; +type Orientation = keyof typeof VB; + +const LANE_KEY = "home-2|home-9"; + +/** Resolves a CSS custom property to the rgb() string the browser paints. */ +async function toRgb(page: Page, cssVar: string): Promise { + return page.evaluate((v) => { + const el = document.createElement("div"); + el.style.color = `var(${v})`; + document.body.appendChild(el); + const rgb = getComputedStyle(el).color; + el.remove(); + return rgb; + }, cssVar); +} + +function parseRgb(value: string): [number, number, number] { + const m = value.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); + expect(m, `not an rgb value: ${value}`).not.toBeNull(); + return [Number(m![1]), Number(m![2]), Number(m![3])]; +} + +// Phone emulation shrinks the visual viewport after any input focus and never +// restores it, so point-based clicks can land on the wrong element (see the +// same note in e2e/roster.spec.ts). Dispatching targets the element directly. +async function robustClick(locator: Locator) { + await locator.scrollIntoViewIfNeeded(); + await locator.dispatchEvent("click"); +} + +async function orientationOf(page: Page): Promise { + return (await page.locator(".board-wrap").getAttribute("data-orientation")) as Orientation; +} + +async function modelToClient(page: Page, m: { x: number; y: number }) { + const o = await orientationOf(page); + const box = (await page.getByTestId("board").boundingBox())!; + const vb = VB[o]; + const p = + o === "portrait" + ? { px: (m.y / 100) * vb.width, py: ((100 - m.x) / 100) * vb.height } + : { px: (m.x / 100) * vb.width, py: (m.y / 100) * vb.height }; + return { x: box.x + (p.px / vb.width) * box.width, y: box.y + (p.py / vb.height) * box.height }; +} + +async function dragTokenTo(page: Page, id: string, m: { x: number; y: number }) { + const b = (await page.locator(`[data-token-id="${id}"]`).boundingBox())!; + await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2); + await page.mouse.down(); + const target = await modelToClient(page, m); + await page.mouse.move(target.x, target.y, { steps: 12 }); + await page.mouse.up(); +} + +/** Everything on the pitch whose colour carries a football meaning. */ +async function readPitch(page: Page) { + return { + turf: await page + .locator(".board-wrap") + .evaluate((el) => getComputedStyle(el).backgroundColor), + home: await page + .locator('[data-token-id="home-2"] .token-face') + .evaluate((el) => getComputedStyle(el).stroke), + away: await page + .locator('[data-token-id="away-11"] .token-face') + .evaluate((el) => getComputedStyle(el).stroke), + lane: await page + .locator(`[data-lane-key="${LANE_KEY}"]`) + .evaluate((el) => getComputedStyle(el).stroke), + }; +} + +test("the pitch, the teams and the lanes never read the chrome accent", async ({ + page, + issues, +}) => { + await registerCoach(page); + + // A clean horizontal lane near the top touchline, clear of opponents, then + // confirm it by clicking the two teammates (same setup as e2e/lanes.spec). + await dragTokenTo(page, "home-2", { x: 30, y: 8 }); + await dragTokenTo(page, "home-9", { x: 70, y: 8 }); + await page.locator('[data-token-id="home-2"]').click(); + await page.locator('[data-token-id="home-9"]').click(); + const lane = page.locator(`[data-lane-key="${LANE_KEY}"]`); + await expect(lane).toHaveAttribute("data-lane-status", "confirmed"); + + for (const theme of THEMES) { + await robustClick(page.getByTestId(`theme-switch-${theme}`)); + await expect(page.locator("html")).toHaveAttribute("data-theme", theme); + + // DoD: "Each page matches its PNGs across the three themes." The pitch + // is a pitch in every theme: the turf is a green, not a chrome colour. + const before = await readPitch(page); + const [r, g, b] = parseRgb(before.turf); + expect(g, `${theme}: turf must be green, got ${before.turf}`).toBeGreaterThan(r); + expect(g, `${theme}: turf must be green, got ${before.turf}`).toBeGreaterThan(b); + + // Home and away cannot collapse into one colour, whatever the brand is. + expect(before.home, `${theme}: home and away collide`).not.toBe(before.away); + + // A confirmed lane ("the pass is on") is not the away/blocked red. + expect(before.lane, `${theme}: confirmed lane looks like the opposition`).not.toBe( + before.away + ); + + // THE INVARIANT. Force the chrome's red family to an unmistakable green + // at the document root, which beats every theme declaration. If any + // football colour is still wired to the chrome, it moves. Nothing may. + await page.evaluate(() => { + const s = document.documentElement.style; + s.setProperty("--accent", "rgb(0, 255, 0)"); + s.setProperty("--glow", "rgb(0, 255, 0)"); + s.setProperty("--red", "rgb(0, 255, 0)"); + s.setProperty("--accent-ink", "rgb(0, 255, 0)"); + }); + + // The override really is live: chrome inside the board panel moved. + await expect(page.getByTestId("select-tool")).toHaveCSS( + "background-color", + "rgb(0, 255, 0)" + ); + + const after = await readPitch(page); + expect(after.turf, `${theme}: the accent repainted the turf`).toBe(before.turf); + expect(after.home, `${theme}: the accent repainted the home team`).toBe(before.home); + expect(after.away, `${theme}: the accent repainted the away team`).toBe(before.away); + expect(after.lane, `${theme}: the accent repainted a confirmed lane`).toBe(before.lane); + + await page.evaluate(() => { + const s = document.documentElement.style; + for (const p of ["--accent", "--glow", "--red", "--accent-ink"]) s.removeProperty(p); + }); + } + + // A blocked lane is not a confirmed lane. This is the pair a red brand + // accent would have collapsed: "this pass is on" and "this pass is blocked". + const confirmed = await lane.evaluate((el) => getComputedStyle(el).stroke); + await dragTokenTo(page, "away-11", { x: 50, y: 14 }); + await expect(lane).toHaveAttribute("data-lane-status", "blocked"); + const blocked = await lane.evaluate((el) => getComputedStyle(el).stroke); + expect(blocked, "confirmed and blocked lanes are the same colour").not.toBe(confirmed); + const interceptDot = page.locator(`[data-lane-dot="${LANE_KEY}"]`); + await expect(interceptDot).toHaveCount(1); + expect(await interceptDot.evaluate((el) => getComputedStyle(el).fill)).toBe(blocked); + + await assertCleanPage(page, issues); +}); + +test("brand red is the only interactive fill and advisories stay gold", async ({ + page, + issues, +}) => { + const { joinCode } = await registerCoach(page); + + // Two players that trigger the coach-only double-exposure fit warning: a + // High-AWR right fullback behind a High-AWR right winger (Brief section 5, + // Screens, Roster line). + await robustClick(page.getByTestId("nav-roster")); + await expect(page.getByRole("heading", { name: "Roster" })).toBeVisible(); + for (const p of [ + { name: "Maya K.", jersey: "7", role: "inside_forward", dwr: "low" }, + { name: "Jordan T.", jersey: "2", role: "overlapping_fb", dwr: "med" }, + ]) { + await robustClick(page.getByTestId("roster-add-player")); + await page.getByTestId("player-name").fill(p.name); + await page.getByTestId("player-jersey").fill(p.jersey); + await page.getByTestId("player-role").selectOption(p.role); + await page.getByTestId("player-flank").selectOption("right"); + await page.getByTestId("player-awr").selectOption("high"); + await page.getByTestId("player-dwr").selectOption(p.dwr); + await robustClick(page.getByTestId("player-save")); + await expect(page.getByTestId("player-save")).toHaveCount(0); + } + const warning = page.getByTestId("fit-warning-right"); + await expect(warning).toBeVisible(); + + const seenAccent = new Set(); + const seenWarn = new Set(); + + for (const theme of THEMES) { + await robustClick(page.getByTestId(`theme-switch-${theme}`)); + await expect(page.locator("html")).toHaveAttribute("data-theme", theme); + + const accent = await toRgb(page, "--accent"); + const warn = await toRgb(page, "--warn"); + const red = await toRgb(page, "--red"); + seenAccent.add(accent); + seenWarn.add(warn); + + // The three chrome families are three different colours. The whole point + // of the split: an action, an advisory, and a failure never look alike. + expect(accent, `${theme}: accent and status red are the same colour`).not.toBe(red); + expect(accent, `${theme}: accent and warn are the same colour`).not.toBe(warn); + + // DoD: an advisory is never a call to action. The coach-only fit warning + // wears shield gold, never the interactive brand red. + const warningBorder = await warning.evaluate((el) => getComputedStyle(el).borderTopColor); + expect(warningBorder, `${theme}: fit warning is not gold`).toBe(warn); + expect(warningBorder, `${theme}: fit warning looks like a button`).not.toBe(accent); + + // DoD: the interactive colour is the brand red, and it is a FILL. + const addBg = await page + .getByTestId("roster-add-player") + .evaluate((el) => getComputedStyle(el).backgroundColor); + expect(addBg, `${theme}: the primary button is not the accent`).toBe(accent); + expect(addBg, `${theme}: the primary button wears the status red`).not.toBe(red); + expect(addBg, `${theme}: the primary button wears the advisory gold`).not.toBe(warn); + } + + // Every theme painted its own value: proves these read the CSS variables + // rather than a colour baked into a component. + expect(seenAccent.size).toBe(3); + expect(seenWarn.size).toBe(3); + + await assertCleanPage(page, issues); + + // DoD (README roles table): fit warnings are coach-only. They are ABSENT + // from a player's DOM, not merely restyled by this ticket's palette work. + const playerContext = await page.context().browser()!.newContext({ + viewport: page.viewportSize() ?? undefined, + }); + const playerPage = await playerContext.newPage(); + await registerPlayer(playerPage, joinCode); + await robustClick(playerPage.getByTestId("nav-roster")); + await expect(playerPage.getByRole("heading", { name: "Roster" })).toBeVisible(); + await expect(playerPage.getByTestId("fit-warning-right")).toHaveCount(0); + await expect(playerPage.locator(".fit-warning")).toHaveCount(0); + await playerContext.close(); +}); diff --git a/e2e/patterns.spec.ts b/e2e/patterns.spec.ts index 4631816..214443a 100644 --- a/e2e/patterns.spec.ts +++ b/e2e/patterns.spec.ts @@ -206,7 +206,7 @@ test.describe("patterns: libraries, chips, search, meta bar, details, open on wh }); }); -test.describe("patterns: matches across all three themes, gold-only interactive, red never a CTA", () => { +test.describe("patterns: matches across all three themes, brand red interactive, gold for status", () => { test("tabs, chips, and the Details action are theme-driven, never red", async ({ page, issues }) => { await registerCoach(page); await page.getByTestId("nav-patterns").click(); diff --git a/e2e/personnel-panel.spec.ts b/e2e/personnel-panel.spec.ts new file mode 100644 index 0000000..19e8032 --- /dev/null +++ b/e2e/personnel-panel.spec.ts @@ -0,0 +1,303 @@ +// Personnel panel journey (T-107, doc 06 sections 2.6, 2.7, 5.3, 5.4). +// Runs under both Playwright projects: desktop landscape at 1440x900, +// where the panel's four groups (Goalkeeper, Back line, Midfield, Front +// line) stack inside the sheet and scroll, and iPhone 13 at 390x844, +// where doc 06 section 5.4's "full-height sheet, one unit at a time" +// pages through them instead. No feature is desktop-only, so every +// assertion below runs at both viewports; the only difference is that a +// phone pages to the group a slot lives in before touching it. +// +// Covers this ticket's DoD lines: +// open the panel; +// the empty-roster state (archetypes and suggestions still work with no +// players on the team at all); +// assign a player to a slot; +// pick an archetype (via a suggestion tap, which exercises both DoD +// lines and the "cited reason, never a score" non-negotiable at once); +// see the top three suggestions with cited reasons; +// see a unit balance note appear as archetypes change; +// see a footedness note. +// Plus the role split: a player token gets the whole editing surface +// (roster and archetype are both open-to-both-roles library/roster reads) +// but none of the three coach-only reads, and fires none of their +// requests either, so T-106's own tripwire spec keeps meaning what it says. + +import { test, expect, assertCleanPage, registerCoach, registerPlayer } from "./fixtures"; +import type { Page } from "@playwright/test"; + +/** True on the phone project, same detection tactics-lab.spec.ts already + * uses: read it off the DOM rather than the project name, so the test + * follows whichever layout the app actually chose. */ +async function isPhone(page: Page): Promise { + return (await page.getByTestId("formations-phase-toggle").count()) > 0; +} + +async function openFormations(page: Page) { + await page.getByTestId("nav-formations").click(); + await expect(page.getByTestId("formations-meta-bar")).toContainText("4-3-3"); +} + +async function openPersonnelPanel(page: Page) { + const handle = page.getByTestId("formations-sheet-handle"); + if ((await page.getByTestId("formations-sheet-body").count()) === 0) { + await handle.click(); + } + await expect(page.getByTestId("formations-sheet-body")).toBeVisible(); + await page.getByTestId("formations-sheet-tab-personnel").click(); + await expect(page.getByTestId("personnel-panel")).toBeVisible(); +} + +/** Doc 06 section 5.4: on phone, the panel shows one group at a time. + * Pages Next until the visible group's label starts with `groupLabel`, + * a no-op on desktop where every group is already stacked in the DOM. */ +async function goToGroup(page: Page, phone: boolean, groupLabel: string) { + if (!phone) return; + for (let i = 0; i < 6; i += 1) { + const label = (await page.getByTestId("personnel-pager-label").textContent()) ?? ""; + if (label.startsWith(groupLabel)) return; + await page.getByTestId("personnel-pager-next").click(); + } + throw new Error(`Personnel pager never reached group "${groupLabel}"`); +} + +/** The open sheet is a fixed-position drawer over the whole page (same as + * every other sheet in this app), so a nav click underneath it needs the + * sheet closed first or it just intercepts the click. */ +async function closeSheet(page: Page) { + if ((await page.getByTestId("formations-sheet-body").count()) > 0) { + await page.getByTestId("formations-sheet-handle").click(); + await expect(page.getByTestId("formations-sheet-body")).toHaveCount(0); + } +} + +/** Chromium's mobile+touch emulation shrinks the visual viewport once a + * text input is focused and never restores it, which can put the save + * button's LAYOUT position out from under its actual location for the + * rest of the page's life (e2e/roster.spec.ts hit this first; same fix: + * dispatch the click directly on the element rather than through + * coordinate math). */ +async function robustClick(page: Page, testId: string) { + const locator = page.getByTestId(testId); + await locator.scrollIntoViewIfNeeded(); + await locator.dispatchEvent("click"); +} + +async function addMinimalPlayer(page: Page, name: string) { + await page.getByTestId("nav-roster").click(); + await expect(page.getByRole("heading", { name: "Roster" })).toBeVisible(); + await robustClick(page, "roster-add-player"); + await page.getByTestId("player-name").fill(name); + // Preferred foot defaults to "R" (RosterPage.tsx EMPTY_FORM), which is + // exactly what the footedness assertion below wants: a right-footed + // player at the left centre back slot fires doc 06 section 2.7 rule 1 + // without this journey having to touch the foot selector at all. + await robustClick(page, "player-save"); + await expect(page.getByTestId("player-save")).toHaveCount(0); +} + +test.describe("personnel panel: empty roster, assignment, suggestions, balance, footedness", () => { + test("coach journey", async ({ page, issues }) => { + await registerCoach(page); + await openFormations(page); + const phone = await isPhone(page); + + // ------------------------------------------------------------------ + // DoD: open the panel. It is the sheet's second segment, alongside + // "Browse formations" (doc 06 section 5.3). + // ------------------------------------------------------------------ + await openPersonnelPanel(page); + await expect(page.getByTestId("personnel-slot")).toHaveCount(phone ? 1 : 11); + + // ------------------------------------------------------------------ + // DoD: the empty-roster state. No players exist on this team yet, and + // the panel still works: archetypes list, suggestions still return a + // usable (if player-less) top three, nothing blocks and nothing + // errors (doc 06 section 5.3: "the panel still works with archetypes + // alone and no players assigned"). + // ------------------------------------------------------------------ + await expect(page.getByTestId("personnel-empty-roster")).toBeVisible(); + + await goToGroup(page, phone, "Back line"); + const cbLeftRow = page.locator('[data-testid="personnel-slot"][data-slot="cb_l"]'); + await expect(cbLeftRow).toBeVisible(); + + // The player picker has nothing but "Unassigned" to offer. + const cbPlayerSelect = cbLeftRow.getByTestId("personnel-player-select"); + await expect(cbPlayerSelect.locator("option")).toHaveCount(1); + + // Suggestions still render for a slot with no player attached, and + // say so rather than pretending to know a fit (backend/app/routers/ + // tactics.py suggest_archetypes: "No player assigned yet ..."). + await expect(cbLeftRow.getByTestId("personnel-suggestion")).toHaveCount(3); + await expect(cbLeftRow.getByTestId("personnel-suggestion-why").first()).toContainText( + "No player assigned yet" + ); + + // ------------------------------------------------------------------ + // Add one player to the roster, then come back. + // ------------------------------------------------------------------ + await closeSheet(page); + await addMinimalPlayer(page, "Robbie Foot"); + await openFormations(page); + await openPersonnelPanel(page); + await goToGroup(page, phone, "Back line"); + + // ------------------------------------------------------------------ + // DoD: assign a player to a slot. + // ------------------------------------------------------------------ + const cbRow = page.locator('[data-testid="personnel-slot"][data-slot="cb_l"]'); + await cbRow.getByTestId("personnel-player-select").selectOption({ label: "Robbie Foot" }); + + // ------------------------------------------------------------------ + // DoD: see the top three suggestions with cited reasons. The why + // cites the actual reason ("passing range 5 and positional discipline + // 4 fit the metronome"), never a score: assert it carries a real + // attribute value (a digit) and never a percent sign. + // ------------------------------------------------------------------ + const cbSuggestions = cbRow.getByTestId("personnel-suggestion"); + await expect(cbSuggestions).toHaveCount(3); + // Wait for the refetched (player-aware) suggestions to actually land: + // the row already showed 3 "no player assigned" suggestions before the + // select above, same count, so toHaveCount(3) alone cannot tell the + // stale response from the new one. + await expect(cbRow.getByTestId("personnel-suggestion-why").first()).not.toContainText( + "No player assigned yet" + ); + const whys = await cbRow.getByTestId("personnel-suggestion-why").allTextContents(); + expect(whys.some((w) => /\d/.test(w))).toBe(true); + for (const w of whys) { + expect(w).not.toMatch(/%/); + expect(w.toLowerCase()).not.toMatch(/\bscore\b/); + } + + // ------------------------------------------------------------------ + // DoD: pick an archetype, via a suggestion tap (also exercises "the + // why cites the actual reason" against the archetype that actually + // gets applied, not a random one). + // ------------------------------------------------------------------ + const firstPick = cbRow.getByTestId("personnel-suggestion-pick").first(); + const pickedName = (await firstPick.textContent())?.trim() ?? ""; + await firstPick.click(); + await expect(firstPick).toHaveAttribute("aria-pressed", "true"); + await expect(cbRow.getByTestId("personnel-archetype-select")).toHaveValue(/.+/); + await expect(cbRow.getByTestId("personnel-archetype-definition")).toContainText(/\w/); + void pickedName; + + // ------------------------------------------------------------------ + // DoD: see a footedness note. Robbie Foot is right-footed (the + // roster form's default) at the LEFT centre back: doc 06 section 2.7 + // rule 1. + // ------------------------------------------------------------------ + await expect(cbRow.getByTestId("personnel-foot-note")).toContainText( + "Right-footed left centre back" + ); + + // ------------------------------------------------------------------ + // DoD: see a unit balance note appear AS ARCHETYPES CHANGE. The 4-3-3 + // midfield three is six + eight_l + eight_r; assigning "Box Crasher" + // to BOTH eights fires mt_one_box_threat (severity note, doc 06 + // section 2.6's own named imbalance) without needing the six + // assigned at all (max_duty rules run on whatever is assigned). + // ------------------------------------------------------------------ + await goToGroup(page, phone, "Midfield"); + const eightLeft = page.locator('[data-testid="personnel-slot"][data-slot="eight_l"]'); + const eightRight = page.locator('[data-testid="personnel-slot"][data-slot="eight_r"]'); + await expect(eightLeft).toBeVisible(); + + const midfieldBalanceBefore = await page.getByTestId("personnel-balance-unit").allTextContents(); + expect(midfieldBalanceBefore.join(" ")).not.toContain("Two box crashers"); + + // The archetype catalog for the "eight" family loads asynchronously; + // wait for real options before picking one so this does not race + // GET /archetypes?slot_family=eight. + const eightLeftArchetype = eightLeft.getByTestId("personnel-archetype-select"); + const eightRightArchetype = eightRight.getByTestId("personnel-archetype-select"); + await expect(eightLeftArchetype.locator("option")).not.toHaveCount(1); + await eightLeftArchetype.selectOption({ label: "Box Crasher" }); + await eightRightArchetype.selectOption({ label: "Box Crasher" }); + + const midfieldUnit = page + .getByTestId("personnel-balance-unit") + .filter({ hasText: "Midfield three" }); + // Two identical archetypes on the same trio actually fires TWO seeded + // rules at once (mt_one_box_threat AND mt_one_of_each_archetype, doc 06 + // section 2.6's own named imbalances), so assert over the joined text + // rather than a single note element. + await expect + .poll(async () => (await midfieldUnit.getByTestId("personnel-balance-note").allTextContents()).join(" ")) + .toContain("Two box crashers"); + await expect(midfieldUnit.getByTestId("personnel-balance-note").first()).toHaveAttribute( + "data-severity", + "note" + ); + // Reads as a check, never an error (this ticket's own non-negotiable + // and doc 06 section 2.2's copy rule, both apply here): no error-shaped + // words anywhere in the balance section. + const balanceText = (await page.getByTestId("personnel-balance").textContent()) ?? ""; + expect(balanceText.toLowerCase()).not.toContain("invalid"); + expect(balanceText.toLowerCase()).not.toContain("error"); + + await assertCleanPage(page, issues); + }); +}); + +test.describe("personnel panel: a player gets the editing surface, none of the coach-only reads", () => { + // Every coach-only fetcher this panel owns (suggestArchetypes, + // evaluateUnitBalance) must never fire for a player token: the API + // itself 403s them (backend/app/routers/tactics.py, tested in + // backend/tests/test_permissions.py and test_tactics_routes.py), and if + // FormationsPage.tsx ever called them unconditionally, assertCleanPage + // below would catch the 403 as a failed request. This is the T-107 half + // of T-106's own tripwire ("player role reaches every control with no + // failed request"). + test("player role: full editing surface, zero coach-only DOM, zero coach-only request", async ({ + page, + issues, + browser, + }) => { + const coachPage = await browser.newPage(); + const { joinCode } = await registerCoach(coachPage); + await coachPage.close(); + + await registerPlayer(page, joinCode); + await openFormations(page); + const phone = await isPhone(page); + + await openPersonnelPanel(page); + await expect(page.getByTestId("personnel-slot")).toHaveCount(phone ? 1 : 11); + + // Player picker and archetype picker are OPEN to both roles (the + // roster itself and the archetype catalog are both player-viewable + // reads, same standing as the rest of the roster page). + const slot = page.getByTestId("personnel-slot").first(); + await expect(slot.getByTestId("personnel-player-select")).toBeVisible(); + const archetypeSelect = slot.getByTestId("personnel-archetype-select"); + await expect(archetypeSelect).toBeVisible(); + await expect(archetypeSelect.locator("option")).not.toHaveCount(1); + await archetypeSelect.selectOption({ index: 1 }); + await expect(slot.getByTestId("personnel-archetype-definition")).toBeVisible(); + + // Suggestions, footedness, and unit balance are COACH-ONLY (doc 06 + // sections 2.7, 5.3) and must be ABSENT from the DOM, not merely + // hidden, across every slot and every group. + await expect(page.getByTestId("personnel-suggestions")).toHaveCount(0); + await expect(page.getByTestId("personnel-foot-note")).toHaveCount(0); + await expect(page.getByTestId("personnel-balance")).toHaveCount(0); + + if (phone) { + // Walk every group's page to prove the coach-only sections are + // absent everywhere, not just on whichever group happened to be + // showing first. + for (const label of ["Back line", "Midfield", "Front line"]) { + await goToGroup(page, true, label); + await expect(page.getByTestId("personnel-suggestions")).toHaveCount(0); + await expect(page.getByTestId("personnel-foot-note")).toHaveCount(0); + } + } + + // assertCleanPage fails on ANY failed request (net-level) or 5xx; a + // 403 from an unconditionally-fired suggest/balance call would show up + // here as a failed fetch the moment the panel's role gate breaks. + await assertCleanPage(page, issues); + }); +}); diff --git a/e2e/roster.spec.ts b/e2e/roster.spec.ts index da461c5..bf8d27d 100644 --- a/e2e/roster.spec.ts +++ b/e2e/roster.spec.ts @@ -247,7 +247,7 @@ test.describe("roster: fit warning and CRUD controls are coach-only, absent from }); test.describe("roster: matches across all three themes", () => { - test("fit warning is red-status-only and the active row uses the theme's gold accent", async ({ + test("fit warning is advisory gold and the active row uses the brand accent", async ({ page, issues, }) => { @@ -292,12 +292,12 @@ test.describe("roster: matches across all three themes", () => { seenWarningBorder.add(warningBorder); seenActiveRowBorder.add(rowBorder); - // Gold is the only interactive color, red never a call to action: - // the selected row's border (interactive state) never matches the - // fit warning's red border in any theme. + // T-071: the fit warning is an advisory, so it wears shield gold, and + // the selected row's border (an interactive state) wears the brand red. + // They never match in any theme, which is the whole point of the split. expect(rowBorder).not.toBe(warningBorder); - // The Add player button (an interactive call to action) uses gold, - // matching the selected row's border color, never the warning's red. + // The Add player button (an interactive call to action) uses the brand + // accent, matching the selected row's border, never the warning's gold. expect(saveBg).not.toBe("rgba(0, 0, 0, 0)"); expect(saveBg).not.toBe(warningBorder); } diff --git a/e2e/tactics-lab.spec.ts b/e2e/tactics-lab.spec.ts new file mode 100644 index 0000000..1a191b4 --- /dev/null +++ b/e2e/tactics-lab.spec.ts @@ -0,0 +1,377 @@ +// Tactics Lab journey for the Formations page (T-106, doc 06 sections 5.1, +// 5.2, 5.4). Runs under both Playwright projects: desktop landscape at +// 1440x900 and iPhone 13 at 390x844, where the board renders PORTRAIT and +// the meta bar collapses to an icon row whose controls open bottom sheets. +// No feature is desktop-only, so every assertion below runs at both +// viewports; the only difference is that a phone opens a control's sheet +// before touching what is inside it. +// +// Covers the ticket's DoD lines: +// selecting each phase and seeing the board morph and the caption change; +// turning opposition on, picking an opponent formation and phase, and +// seeing computed ratios appear; +// opening a rotation and seeing its risk line; +// toggling the grid and seeing a breach check. +// Plus the two rules the epic exists for: a computed ratio is never +// mistakable for the seeded fallback, and a breach reads as a check rather +// than an error. + +import { test, expect, assertCleanPage, registerCoach, registerPlayer } from "./fixtures"; +import type { Page } from "@playwright/test"; + +/** True on the phone project, where doc 06 section 5.4 collapses the meta + * bar to icons and every control opens a bottom sheet. Detected from the + * DOM (the Phase icon exists only in the icon row) rather than from the + * project name, so the test follows the layout the app actually chose. */ +async function isPhone(page: Page): Promise { + return (await page.getByTestId("formations-phase-toggle").count()) > 0; +} + +async function openFormations(page: Page) { + await page.getByTestId("nav-formations").click(); + await expect(page.getByTestId("formations-meta-bar")).toContainText("4-3-3"); + await expect(page.locator("[data-token-id]")).toHaveCount(11); +} + +async function tokenTransform(page: Page, id: string): Promise { + return page.locator(`[data-token-id="${id}"]`).getAttribute("transform"); +} + +/** Selects a phase, opening the phone's Phase sheet first if there is one, + * and leaving the board unobstructed afterwards. */ +async function selectPhase(page: Page, key: string) { + if (await isPhone(page)) { + await page.getByTestId("formations-phase-toggle").click(); + await expect(page.getByTestId("formations-phase-panel")).toBeVisible(); + } + await page.getByTestId(`formations-phase-${key}`).click(); + if (await page.getByTestId("formations-phase-close").count()) { + await page.getByTestId("formations-phase-close").click(); + } +} + +test.describe("tactics lab: phase morph, opposition, live counts, rotations, grid", () => { + test("full coach journey", async ({ page, issues }) => { + await registerCoach(page); + await openFormations(page); + const phone = await isPhone(page); + + // ------------------------------------------------------------------ + // DoD: selecting each phase morphs the board and changes the caption. + // ------------------------------------------------------------------ + const caption = page.getByTestId("formations-phase-caption"); + await expect(caption).toContainText("base shape"); + const baseGk = await tokenTransform(page, "gk"); + + // With the ball: seeds/formation_phases.json puts the 4-3-3 keeper at + // x 14 in possession against x 5 at base, so the keeper is a token that + // demonstrably walks in every phase rather than one that happens to sit + // still. + await selectPhase(page, "in_possession"); + await expect(caption).toContainText("Trigger:"); + await expect.poll(() => tokenTransform(page, "gk")).not.toBe(baseGk); + const ipGk = await tokenTransform(page, "gk"); + const ipCaption = (await caption.textContent()) ?? ""; + + // Without the ball: a different shape again, and a different caption. + await selectPhase(page, "out_of_possession"); + await expect.poll(() => tokenTransform(page, "gk")).not.toBe(ipGk); + await expect(caption).not.toHaveText(ipCaption); + await expect(caption).toContainText("Trigger:"); + const oopGk = await tokenTransform(page, "gk"); + + // Rest defence. + await selectPhase(page, "rest_defence"); + await expect.poll(() => tokenTransform(page, "gk")).not.toBe(oopGk); + await expect(caption).toContainText("Trigger:"); + + // Back to base closes the loop, and all eleven are still on the board: + // a morph binds by slot, it never drops or duplicates a player. + await selectPhase(page, "base"); + await expect.poll(() => tokenTransform(page, "gk")).toBe(baseGk); + await expect(page.locator('[data-token-side="home"]')).toHaveCount(11); + + // ------------------------------------------------------------------ + // The seeded fallback chip, BEFORE any opposition is placed. Doc 06 + // section 5.1: muted, and the seeded ratio only. + // ------------------------------------------------------------------ + await page.getByTestId("formations-rondo-toggle").click(); + // SIX zones (doc 06 section 0), and only five of them are polygons: the + // counterpress ring is a circle around the ball (doc 06 section 2.3), + // so it is not a `rondo-zone` and never will be. + await expect(page.getByTestId("rondo-zone")).toHaveCount(5); + await expect(page.getByTestId("formations-rondo-ring")).toHaveCount(1); + // The ring's seeded polygon is still never drawn. That polygon bounds + // the half of the pitch the ring is coached in; it is not the zone, and + // filling it would show an eleven-a-side count in the same language as + // a real 4v2. + await expect(page.locator('[data-zone-key="counterpress_ring"]')).toHaveCount(0); + + const chips = page.getByTestId("formations-zone-chip"); + await expect(chips).toHaveCount(6); + for (const chip of await chips.all()) { + await expect(chip).toHaveAttribute("data-source", "seeded"); + } + const seededColor = await chips + .first() + .evaluate((el) => getComputedStyle(el).color); + + // The zone card says out loud that this is a coached rondo, not a count. + await page.locator('[data-zone-key="midfield_box"]').click(); + await expect(page.getByTestId("formations-zone-fallback")).toContainText( + "not a count of what is on the board" + ); + await page.getByTestId("formations-zone-close").click(); + + // ------------------------------------------------------------------ + // DoD: turn opposition on, pick an opponent formation and phase, and + // see COMPUTED ratios appear. + // ------------------------------------------------------------------ + await page.getByTestId("formations-opposition-toggle").click(); + await expect(page.getByTestId("formations-opposition-panel")).toBeVisible(); + + await page.getByTestId("formations-opponent-formation").selectOption("442"); + // The opponent phase picker opens on an out-of-possession variant, + // because that is the one that matters (doc 06 section 5.1). It is a + // real seeded variant, not the empty "their base shape" option. + await expect(page.getByTestId("formations-opponent-phase")).not.toHaveValue(""); + await page.getByTestId("formations-opponent-phase").selectOption({ index: 1 }); + + // Twenty two tokens: our eleven plus theirs, in the opponent colour the + // board already defines, mirrored into our frame by the board engine. + await expect(page.locator('[data-token-side="away"]')).toHaveCount(11); + + // A seeded pair (4-3-3 against 4-4-2) carries a coached route, so the + // route line is NOT flagged inferred and the edges are rendered. + await expect(page.getByTestId("formations-read")).toBeVisible(); + await expect(page.getByTestId("formations-read-route")).toHaveAttribute("data-inferred", "false"); + await expect(page.getByTestId("formations-read-unseeded")).toHaveCount(0); + await expect(page.getByTestId("formations-read-our-edges")).toBeVisible(); + // Every card names which superiority it is talking about (doc 06 2.1). + await expect(page.getByTestId("formations-read-spare")).toContainText(/superiority/i); + + await page.getByTestId("formations-opposition-close").click(); + + // Now the chips are COMPUTED, and visibly so: a different data-source, + // a verdict, and a different colour from the muted fallback above. All + // six, the ring included: countZone reads a circle the same way it + // reads a polygon. + await expect(chips).toHaveCount(6); + for (const chip of await chips.all()) { + await expect(chip).toHaveAttribute("data-source", "computed"); + await expect(chip).toHaveAttribute("data-verdict", /superiority|parity|inferiority/); + await expect(chip).toHaveText(/\d+v\d+/); + } + const computedColor = await chips.first().evaluate((el) => getComputedStyle(el).color); + expect(computedColor, "a computed ratio must not look like a seeded one").not.toBe(seededColor); + + // The tapped zone card carries the computed read, naming the superiority. + await page.locator('[data-zone-key="midfield_box"]').click(); + await expect(page.getByTestId("formations-zone-fallback")).toHaveCount(0); + await expect(page.getByTestId("formations-zone-read")).toContainText( + /Numerical superiority|Numerical inferiority|Parity/ + ); + await page.getByTestId("formations-zone-close").click(); + + // ------------------------------------------------------------------ + // An UNSEEDED pair. Doc 06 section 2.8: render the computed numbers, + // say plainly there is no coached read, and label the route inferred. + // formation_matchups seeds the 15 unordered pairs of six formations, so + // a formation against ITSELF is the pair nobody wrote a card for. + // ------------------------------------------------------------------ + await page.getByTestId("formations-opposition-toggle").click(); + await page.getByTestId("formations-opponent-formation").selectOption("433"); + await expect(page.getByTestId("formations-read-unseeded")).toContainText("no coached read yet"); + await expect(page.getByTestId("formations-read-route")).toHaveAttribute("data-inferred", "true"); + await expect(page.getByTestId("formations-read-route")).toContainText("probably"); + // The numbers are still real: the counts are computed either way. + await expect(page.getByTestId("formations-read-spare")).toBeVisible(); + await page.getByTestId("formations-opposition-close").click(); + await expect(chips.first()).toHaveAttribute("data-source", "computed"); + + // ------------------------------------------------------------------ + // DoD: open a rotation and see its risk line, with equal visual weight + // to the gain (doc 06 section 5.1: "the risk line is not a footnote"). + // ------------------------------------------------------------------ + await page.getByTestId("formations-rotations-toggle").click(); + await expect(page.getByTestId("formations-rotations-panel")).toBeVisible(); + // Mutual exclusion: opening Rotations closes the Rondo map overlay, + // the ring with it. + await expect(page.getByTestId("rondo-zone")).toHaveCount(0); + await expect(page.getByTestId("formations-zone-chip")).toHaveCount(0); + await expect(page.getByTestId("formations-rondo-ring")).toHaveCount(0); + + const rotationItems = page.getByTestId("formations-rotation-item"); + expect(await rotationItems.count()).toBeGreaterThan(0); + await rotationItems.first().click(); + await expect(page.getByTestId("formations-rotation-card")).toBeVisible(); + await expect(page.getByTestId("formations-rotation-trigger")).not.toBeEmpty(); + await expect(page.getByTestId("formations-rotation-moves")).not.toBeEmpty(); + await expect(page.getByTestId("formations-rotation-points")).not.toBeEmpty(); + + const gain = page.getByTestId("formations-rotation-gain"); + const risk = page.getByTestId("formations-rotation-risk"); + await expect(risk).toBeVisible(); + await expect(risk).toContainText(/\w/); + const style = async (loc: typeof gain) => + loc.locator("p").last().evaluate((el) => { + const s = getComputedStyle(el); + return { fontSize: s.fontSize, color: s.color, opacity: s.opacity }; + }); + const gainStyle = await style(gain); + const riskStyle = await style(risk); + expect(riskStyle, "the risk line is not a footnote").toEqual(gainStyle); + const gainBox = await gain.boundingBox(); + const riskBox = await risk.boundingBox(); + expect(Math.abs((gainBox?.width ?? 0) - (riskBox?.width ?? 1))).toBeLessThanOrEqual(1); + + // The rotation plays on the board: its animation spec is a vignette of + // the players who move, plus the ball. + await expect(page.locator('[data-token-side="ball"]')).toHaveCount(1); + + // ------------------------------------------------------------------ + // DoD: toggle the grid and see a breach CHECK, never an error. + // ------------------------------------------------------------------ + await page.getByTestId("formations-grid-toggle").click(); + await expect(page.getByTestId("formations-grid-panel")).toBeVisible(); + // Mutual exclusion again: only one of Rondo, Rotations and Grid. + await expect(page.getByTestId("formations-rotations-panel")).toHaveCount(0); + // The eleven are back: closing the rotation restored the phase scene. + await expect(page.locator('[data-token-side="home"]')).toHaveCount(11); + + await expect(page.getByTestId("formations-grid-layer")).toBeVisible(); + const checks = page.getByTestId("formations-grid-check"); + // The 4-3-3 base shape breaches on the centre lane and both wide lanes + // (seeds/formations.json against doc 06 section 2.2's guidelines), so + // there is always something to read here. + expect(await checks.count()).toBeGreaterThan(0); + await expect(page.getByTestId("formations-grid-band").first()).toBeVisible(); + const checkText = (await checks.allTextContents()).join(" "); + expect(checkText).toContain("?"); + expect(checkText.toLowerCase()).not.toContain("invalid"); + expect(checkText.toLowerCase()).not.toContain("wrong"); + expect(checkText.toLowerCase()).not.toContain("error"); + + await page.getByTestId("formations-grid-close").click(); + await expect(page.getByTestId("formations-grid-layer")).toHaveCount(0); + + await assertCleanPage(page, issues); + }); +}); + +test.describe("tactics lab: a player gets the whole lab, because none of it is coach-only", () => { + // Every route this page calls is library world (formation phases, + // matchups, rotations): no team_id, no coach gate, visible to both roles + // by design (backend/app/routers/tactics.py). Nothing here is fit-warning + // or receipt shaped. This journey exists so that stays true: the moment + // someone wires a coach-only call into this page (T-107's archetype + // suggestion ranking is the obvious candidate), a player hits a 403 and + // assertCleanPage fails on the failed request rather than the page + // quietly rendering a hole. + // + // T-107 landed the obvious candidate: the personnel panel's suggestion + // ranking and unit balance read. This journey now walks into that panel + // too (e2e/personnel-panel.spec.ts owns the full role-split coverage; + // this is the narrower "does the ORIGINAL tripwire still mean what it + // said" check, in the file that made the promise). + test("player role reaches every control with no failed request", async ({ + page, + issues, + browser, + }) => { + const coachPage = await browser.newPage(); + const { joinCode } = await registerCoach(coachPage); + await coachPage.close(); + + await registerPlayer(page, joinCode); + await openFormations(page); + + await selectPhase(page, "out_of_possession"); + await expect(page.getByTestId("formations-phase-caption")).toContainText("Trigger:"); + + await page.getByTestId("formations-opposition-toggle").click(); + await page.getByTestId("formations-opponent-formation").selectOption("352"); + await expect(page.getByTestId("formations-read")).toBeVisible(); + await page.getByTestId("formations-opposition-close").click(); + + await page.getByTestId("formations-rotations-toggle").click(); + await expect(page.getByTestId("formations-rotation-item").first()).toBeVisible(); + await page.getByTestId("formations-rotations-close").click(); + + await page.getByTestId("formations-grid-toggle").click(); + await expect(page.getByTestId("formations-grid-check").first()).toBeVisible(); + await page.getByTestId("formations-grid-close").click(); + + // T-107: the personnel panel opens for a player (assignment and the + // archetype picker are open-to-both-roles reads), but its coach-only + // suggestion list, footedness note, and unit balance section are + // absent from the DOM, and none of their requests ever fire (or this + // 403s and assertCleanPage below catches it as a failed request). + await page.getByTestId("formations-sheet-handle").click(); + await page.getByTestId("formations-sheet-tab-personnel").click(); + await expect(page.getByTestId("personnel-panel")).toBeVisible(); + await expect(page.getByTestId("personnel-slot").first()).toBeVisible(); + await expect(page.getByTestId("personnel-suggestions")).toHaveCount(0); + await expect(page.getByTestId("personnel-foot-note")).toHaveCount(0); + await expect(page.getByTestId("personnel-balance")).toHaveCount(0); + await page.getByTestId("formations-sheet-handle").click(); + + await assertCleanPage(page, issues); + }); +}); + +test.describe("tactics lab: themed across all three, brand red interactive, verdicts as status", () => { + test("chips and controls read theme variables, never a baked-in colour", async ({ page, issues }) => { + await registerCoach(page); + await openFormations(page); + + // Place an opposition once, so every theme below is measured against + // live computed chips rather than the muted fallback. + await page.getByTestId("formations-opposition-toggle").click(); + await page.getByTestId("formations-opponent-formation").selectOption("442"); + await page.getByTestId("formations-opposition-close").click(); + await page.getByTestId("formations-rondo-toggle").click(); + await expect(page.getByTestId("formations-zone-chip").first()).toHaveAttribute( + "data-source", + "computed" + ); + + const seenChip = new Set(); + const seenSegment = new Set(); + + for (const theme of ["pitch", "dark", "board"] as const) { + await page.getByTestId(`theme-switch-${theme}`).click(); + await expect(page.locator("html")).toHaveAttribute("data-theme", theme); + + const redRgb = await page.evaluate(() => { + const el = document.createElement("div"); + el.style.color = "var(--red)"; + document.body.appendChild(el); + const rgb = getComputedStyle(el).color; + el.remove(); + return rgb; + }); + + const chip = page.getByTestId("formations-zone-chip").first(); + seenChip.add(await chip.evaluate((el) => getComputedStyle(el).color)); + + // The interactive controls wear the brand accent, which is NOT the + // status red: a verdict on this page must never look like a control + // (T-071 keeps the two reds at distinct values for exactly this). + const grid = page.getByTestId("formations-grid-toggle"); + const gridColor = await grid.evaluate((el) => getComputedStyle(el).borderTopColor); + expect(gridColor).not.toBe(redRgb); + const rondo = page.getByTestId("formations-rondo-active-toggle"); + const rondoBg = await rondo.evaluate((el) => getComputedStyle(el).backgroundColor); + expect(rondoBg).not.toBe(redRgb); + seenSegment.add(rondoBg); + } + + // Three themes, three distinct painted values: proves the chip and the + // active control read CSS variables rather than a hardcoded colour. + expect(seenChip.size).toBe(3); + expect(seenSegment.size).toBe(3); + + await assertCleanPage(page, issues); + }); +}); diff --git a/e2e/whiteboard.spec.ts b/e2e/whiteboard.spec.ts index 7aae15c..0a2d08c 100644 --- a/e2e/whiteboard.spec.ts +++ b/e2e/whiteboard.spec.ts @@ -225,12 +225,13 @@ test.describe("whiteboard: matches across all three themes", () => { await page.getByTestId(`theme-switch-${theme}`).click(); await expect(page.locator("html")).toHaveAttribute("data-theme", theme); await expect(page.getByTestId("board")).toBeVisible(); - // The always-active Select tool is gold in every theme's own accent - // (design README: gold is the only interactive color). + // The always-active Select tool wears every theme's own accent, which + // T-071 made the brand red (the design README's gold-only rule is + // superseded by the founder palette directive of 2026-08-07). seenAccent.add(await accentBg()); seenSurface.add(await surfaceBg()); - // Record, before it is ever pressed, must NOT render in the red status - // color (red is never a call to action). + // Record, before it is ever pressed, is not an active tool, so it must + // not carry the accent fill an active tool does. const recordBg = await page .getByTestId("record") .evaluate((el) => getComputedStyle(el).backgroundColor); diff --git a/frontend/index.html b/frontend/index.html index 05ba0e2..3e87a48 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,6 +4,9 @@ Patterns of Play + + +
diff --git a/frontend/public/apple-touch-icon.png b/frontend/public/apple-touch-icon.png new file mode 100644 index 0000000..4774ce4 Binary files /dev/null and b/frontend/public/apple-touch-icon.png differ diff --git a/frontend/public/favicon-16.png b/frontend/public/favicon-16.png new file mode 100644 index 0000000..ea7532e Binary files /dev/null and b/frontend/public/favicon-16.png differ diff --git a/frontend/public/favicon-32.png b/frontend/public/favicon-32.png new file mode 100644 index 0000000..590c0db Binary files /dev/null and b/frontend/public/favicon-32.png differ diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000..a53d40b Binary files /dev/null and b/frontend/public/favicon.ico differ diff --git a/frontend/public/logo-lockup.png b/frontend/public/logo-lockup.png new file mode 100644 index 0000000..0ae9fdf Binary files /dev/null and b/frontend/public/logo-lockup.png differ diff --git a/frontend/public/shield-mark-96.png b/frontend/public/shield-mark-96.png new file mode 100644 index 0000000..0f5e8fd Binary files /dev/null and b/frontend/public/shield-mark-96.png differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b22b470..a98fa1d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -61,7 +61,16 @@ function MinimalShell({ children }: { children: ReactNode }) {

-

@@ -134,7 +143,7 @@ export default function App() { ) : page === "roster" ? ( ) : page === "formations" ? ( - + ) : page === "identity" ? ( ) : ( diff --git a/frontend/src/AppShell.css b/frontend/src/AppShell.css index 2384a06..bd0fff2 100644 --- a/frontend/src/AppShell.css +++ b/frontend/src/AppShell.css @@ -3,13 +3,14 @@ this file only adds what the full shell needs on top of that. Colors are theme tokens only. */ -/* The lone gold dot beside the wordmark (PNG 01-05 topbar). */ -.app-brand-dot { - width: 9px; - height: 9px; - border-radius: 50%; - background: var(--accent); - box-shadow: 0 0 6px var(--glow); +/* Shield mark beside the wordmark (T-070, replaces the gold dot placeholder + PNG 01-05 topbar shipped with). Transparent-background PNG so it reads on + any theme's chrome; sized by height, width follows the art's own aspect + ratio rather than a fixed box. */ +.app-brand-mark { + display: block; + height: 28px; + width: auto; } .app-topbar-end { diff --git a/frontend/src/AppShell.tsx b/frontend/src/AppShell.tsx index 81dba3f..2fde09b 100644 --- a/frontend/src/AppShell.tsx +++ b/frontend/src/AppShell.tsx @@ -112,7 +112,22 @@ export function AppShell({

-

diff --git a/frontend/src/AuthForms.tsx b/frontend/src/AuthForms.tsx index b9914bf..f681773 100644 --- a/frontend/src/AuthForms.tsx +++ b/frontend/src/AuthForms.tsx @@ -39,6 +39,14 @@ export function AuthForms({ onAuthenticated }: { onAuthenticated: () => void }) return (
+ {/* Decorative (T-070 follow-up): MinimalShell's h1 above this + screen already exposes "Patterns of Play" as the page's + accessible name (App.test.tsx and the e2e journeys assert on + that heading), so this img stays out of the accessibility tree + instead of re-announcing the same text a second time, the same + alt="" + aria-hidden pattern AppShell.tsx and App.tsx use for + the nav/topbar shield mark. */} +

{mode === "register" ? "Register" : "Log in"}

{mode === "register" && ( diff --git a/frontend/src/auth.css b/frontend/src/auth.css index a0725e9..7284417 100644 --- a/frontend/src/auth.css +++ b/frontend/src/auth.css @@ -21,6 +21,16 @@ font-family: var(--body-font); } +/* Full lockup (T-070), shield + stars + wordmark, above the form. Only + .auth-forms uses it (AuthForms.tsx), not .team-onboarding: see the T-070 + report for why the create-team/join-code screens stay text-only. */ +.auth-lockup { + align-self: center; + width: 176px; + max-width: 55%; + height: auto; +} + .auth-form, .team-form { display: flex; diff --git a/frontend/src/board/Board.css b/frontend/src/board/Board.css index 59fe2de..fe44c45 100644 --- a/frontend/src/board/Board.css +++ b/frontend/src/board/Board.css @@ -1,4 +1,19 @@ -/* Board styling. Colors come only from the T-002 theme token variables. +/* Board styling. Colors come only from the theme token variables, and this + file is where the two token layers meet, so the split matters (T-071, + see styles/tokens.css): + + - FOOTBALL SEMANTICS read BOARD tokens. The turf, the players, the ball, + every lane, every ring, every zone. These keep the design README's + pitch language (green turf, gold "the pass is on", red "blocked / + opposition / marking") in all three themes, and they are immune to the + chrome's brand red: changing --accent cannot change what the pitch or + a lane looks like (proved by e2e/palette.spec.ts). + - CHROME INSIDE THE BOARD PANEL reads chrome tokens. The toolbar, the + view menu, the save bar, the saved-pattern list, focus and drag + affordances. --accent (brand red) is the only interactive colour; + --warn (shield gold) is non-interactive emphasis; --red is failure + status and never fills a control. + No hardcoded colors, no layout thrash during drag. */ .board-wrap { @@ -6,7 +21,11 @@ width: 100%; max-width: 1100px; margin: 0 auto; - background: var(--bg-stripe); + /* The SVG paints the turf; this backs it so no chrome colour shows + through at the rounded corners. Board token, never --bg-stripe (which + was a chrome token pretending to be turf before T-071, and no longer + exists). */ + background: var(--pitch-turf); border: 1px solid var(--surface); border-radius: 10px; overflow: hidden; @@ -37,11 +56,14 @@ .token-ball .token-face { /* gold glow on the ball (design README: glowing gold dot) */ - filter: drop-shadow(0 0 6px var(--glow)); + filter: drop-shadow(0 0 6px var(--ball)); stroke: none; } .token-active .token-face { + /* Drag affordance, not football: a token being dragged is an interaction + state, so it reads the chrome's interactive colour. Nothing about the + token's identity (its ring, its number) comes from here. */ filter: drop-shadow(0 0 5px var(--accent)); } @@ -51,15 +73,18 @@ pointer-events: none; } -/* Selected first token while confirming a lane (click two players). Gold is the - only interactive color (design README), so the pairing cue is a gold pulse. */ +/* Selected first token while confirming a lane (click two players). This is an + interaction cue, not a football one, so it reads the chrome accent (T-071: + the brand red is now the interactive colour). The lane it produces is gold. */ .token-pairing .token-face { filter: drop-shadow(0 0 6px var(--accent)); } /* Lane graph (T-021). Stroke widths and dashes are in viewBox user units so - they scale with the board. Colors are theme tokens only: gold for suggested - and confirmed lanes, red for blocked. Red is status only, never interactive. */ + they scale with the board. Colors are BOARD tokens only: gold for suggested + and confirmed lanes, red for blocked and for interception. Never --accent + or --red: those are chrome, and with a red brand accent they would paint + "this pass is on" and "this pass is blocked" the same colour (T-071). */ .lane-layer .lane, .mark-layer .mark-ring { fill: none; @@ -72,7 +97,7 @@ /* Suggested: dashed dim gold (auto passing option within range). */ .lane-suggested { - stroke: var(--accent); + stroke: var(--lane-suggested); stroke-width: 3; stroke-dasharray: 3 9; opacity: 0.45; @@ -80,15 +105,15 @@ /* Confirmed: solid bright gold, coach-locked. */ .lane-confirmed { - stroke: var(--accent); + stroke: var(--lane-confirmed); stroke-width: 4.5; opacity: 1; - filter: drop-shadow(0 0 4px var(--glow)); + filter: drop-shadow(0 0 4px var(--lane-glow)); } /* Blocked: dashed red (a suggested or confirmed lane an opponent sits in). */ .lane-blocked { - stroke: var(--red); + stroke: var(--lane-blocked); stroke-width: 3.5; stroke-dasharray: 8 7; opacity: 0.95; @@ -96,14 +121,14 @@ /* Interception dot: red, sits on the closest point of the pass segment. */ .lane-dot { - fill: var(--red); + fill: var(--intercept); stroke: none; pointer-events: none; } /* Marking rings: thin when marked, thick + glow when tightly marked. */ .mark-ring { - stroke: var(--red); + stroke: var(--mark); } .mark-loose { stroke-width: 2.5; @@ -112,7 +137,7 @@ .mark-tight { stroke-width: 5; opacity: 1; - filter: drop-shadow(0 0 5px var(--red)); + filter: drop-shadow(0 0 5px var(--mark)); } .board-root { @@ -153,14 +178,15 @@ } /* ------------------------------------------------------------------------- - Zone overlays (T-022, Brief step 13). Zones are informational geometry, so - they use gold (the interactive/accent family) at low opacity and never red - (red is status only). They paint behind lanes, rings, and tokens. + Zone overlays (T-022, Brief step 13). Zones are informational football + geometry, so they use the board's gold --zone token at low opacity and + never a red: a zone is a place to play into, not a warning. They paint + behind lanes, rings, and tokens. ------------------------------------------------------------------------- */ .zone-rect { - fill: var(--accent); + fill: var(--zone); fill-opacity: 0.08; - stroke: var(--accent); + stroke: var(--zone); stroke-opacity: 0.5; stroke-width: 1.5; stroke-dasharray: 6 5; @@ -172,14 +198,14 @@ stroke-dasharray: 3 7; } .zone-divider { - stroke: var(--accent); + stroke: var(--zone); stroke-opacity: 0.4; stroke-width: 1.5; stroke-dasharray: 4 8; pointer-events: none; } .zone-label { - fill: var(--accent); + fill: var(--zone); fill-opacity: 0.8; font-family: var(--display-font, sans-serif); font-size: 15px; @@ -189,16 +215,16 @@ /* ------------------------------------------------------------------------- Animation trace (T-022, Brief step 14): glowing gold ball trail + numbered - gold route badges. Gold only. + gold route badges. Board tokens only: the trace belongs to the ball. ------------------------------------------------------------------------- */ .ball-trail { fill: none; - stroke: var(--glow); + stroke: var(--ball); stroke-width: 4; stroke-linecap: round; stroke-linejoin: round; opacity: 0.9; - filter: drop-shadow(0 0 5px var(--glow)); + filter: drop-shadow(0 0 5px var(--ball)); pointer-events: none; } /* Trajectory-driven trail styles (design README: ground flat, floated arced). */ @@ -214,13 +240,13 @@ opacity: 0.8; } .route-badge { - fill: var(--accent); - stroke: var(--glow); + fill: var(--route-badge); + stroke: var(--lane-glow); stroke-width: 1.5; pointer-events: none; } .route-badge-num { - fill: var(--accent-ink, #1b1b1b); + fill: var(--route-badge-ink); font-family: var(--display-font, sans-serif); font-weight: 700; pointer-events: none; @@ -241,7 +267,7 @@ border-radius: 999px; background: var(--surface); border: 1px solid var(--red); - color: var(--red); + color: var(--text-red); font-family: var(--body-font); font-size: 13px; white-space: nowrap; @@ -273,10 +299,10 @@ /* ------------------------------------------------------------------------- Toolbar: a floating pill pinned to the bottom-center of the pitch (PNG - 01-04). Select is the permanent default tool (gold, always active); record - is status, never a call to action (surface fill, red only once active); - play/reset/view-menu are neutral until acted on. Gold is the only - interactive color (design README). + 01-04). Select is the permanent default tool (always active); record is + neutral until it is running; play/reset/view-menu are neutral until acted + on. Every one of these is a control, so they read the chrome's interactive + colour, the brand red --accent (T-071 founder directive). ------------------------------------------------------------------------- */ .board-toolbar-float { position: absolute; @@ -312,11 +338,33 @@ background: var(--accent); color: var(--accent-ink, #1b1b1b); } -/* Record is status, not a call to action: neutral until recording, then a - solid red dot (red is never interactive, only status, design README). */ +/* Recording in progress. It is a control, so it takes the interactive accent + like every other active tool. The select tool is permanently active, so two + accent-filled circles would otherwise sit in the same pill looking alike: + the recording one carries TWO non-colour cues, a ring and a live pulse, + plus the "Recording." banner above the pitch. Colour alone never + distinguishes them, which is the point (T-071: the brand is red now, so + "red circle" no longer means "recording" on its own). */ .tool-btn.tool-record-active { - background: var(--red); - color: var(--surface); + background: var(--accent); + color: var(--accent-ink); + box-shadow: 0 0 0 3px var(--glow); + animation: record-pulse 1.3s ease-in-out infinite; +} +@keyframes record-pulse { + 0%, + 100% { + box-shadow: 0 0 0 3px var(--glow); + } + 50% { + box-shadow: 0 0 0 6px var(--surface); + } +} +/* The ring survives on its own for anyone who has asked for less motion. */ +@media (prefers-reduced-motion: reduce) { + .tool-btn.tool-record-active { + animation: none; + } } .view-menu { position: relative; @@ -483,20 +531,27 @@ opacity: 0.6; cursor: default; } -/* Reusable neutral button: never red (red is status only, never a call to - action, design README), used for Discard and for Delete on saved patterns. */ +/* Reusable neutral button, used for Discard and for Delete on saved patterns: + destructive actions stay neutral rather than borrowing the accent. */ .save-bar .ctl-ghost, .saved-pattern .ctl-ghost { background: transparent; color: var(--text-secondary); border-color: var(--text-secondary); } +/* Failure status. --red never fills a control, so an error is a tinted, + outlined block of red text: a shape the accent never takes, which is what + keeps it from reading as a red button now that the brand is red (T-071). */ .save-error { flex-basis: 100%; margin: 0; + padding: 6px 10px; + border: 1px solid var(--red); + border-radius: 8px; + background: var(--bg-red); font-family: var(--body-font); font-size: 12px; - color: var(--text-red, var(--red)); + color: var(--text-red); } .saved-patterns { @@ -531,10 +586,12 @@ font-size: 14px; flex: 1; } +/* An author stamp is a label, not a control: shield gold, never the + interactive accent (T-071). */ .saved-pattern-author { font-size: 11px; letter-spacing: 0.05em; - color: var(--accent); + color: var(--text-warn); } .saved-pattern button { font: inherit; diff --git a/frontend/src/board/Board.tsx b/frontend/src/board/Board.tsx index befca42..da8f213 100644 --- a/frontend/src/board/Board.tsx +++ b/frontend/src/board/Board.tsx @@ -41,7 +41,7 @@ import { } from "./coords"; import { PitchMarkings } from "./PitchMarkings"; import { FrameScheduler } from "./time"; -import { defaultBoardTokens, TOKEN_FILL, type Token } from "./tokens"; +import { defaultBoardTokens, TOKEN_FACE, TOKEN_FILL, type Token } from "./tokens"; import { computeLanes, computeMarks, @@ -799,7 +799,10 @@ export default function Board({ {token.label && ( void; + /** + * Whether the Restart control appears once a playback settles. Defaults to + * true, which is right for a pattern, a delivery, a rotation or a saved + * recording: those are things you watch again. + * + * A formation phase morph (T-105) is not. It is a transition into a state the + * coach then reads, so a Restart button under it would offer to replay a + * 600ms walk that has already told its story, and would sit on the board for + * as long as the phase is selected. The Formations page passes false. + */ + showRestart?: boolean; } export default function PatternPreviewBoard({ @@ -93,6 +104,7 @@ export default function PatternPreviewBoard({ onTokenClick, zones, onZoneClick, + showRestart = true, }: Props) { const vb = VIEWBOX[orientation]; const svgRef = useRef(null); @@ -261,7 +273,7 @@ export default function PatternPreviewBoard({ r={r} style={{ stroke: TOKEN_FILL[token.side], - fill: token.side === "ball" ? TOKEN_FILL.ball : "var(--surface)", + fill: token.side === "ball" ? TOKEN_FILL.ball : TOKEN_FACE, }} /> {token.label && ( @@ -287,7 +299,7 @@ export default function PatternPreviewBoard({
)} - {playback && !playing && ( + {showRestart && playback && !playing && ( + ); + })} +
+ )} + +
+ + {phone && ( + + )} + + + -
- ) : ( -
+ + +
- )} - {activeKeystone && !rondoOpen && ( -
-
-

Keystone

- + {/* Variant chips: several shapes can be seeded for one phase + (a 4-3-3 has two in-possession variants plus three + reference systems), and without this the page could only + ever reach the first of them. */} + {!phone && phaseKey !== "base" && phaseVariants.length > 1 && ( +
+ {phaseVariants.map((v) => ( + + ))}
-

{activeKeystone.title}

-

{activeKeystone.blurb}

-
- )} + )} +
- {activeZone && rondoOpen && ( -
-
-

Rondo

- +
+ + + {/* Live count chips (doc 06 section 5.1 control 3). A page + overlay rather than a board prop: the chip has to carry a + verdict colour and a muted/computed distinction, which an + SVG label in the shared renderer cannot, and + extending the renderer for one page's styling would be a + fork by another name. Positioned through modelToRender, so + portrait comes out right without this file knowing the + formula. */} + {overlay === "rondo" && ( +
+ {/* The counterpress ring (doc 06 section 2.3). A CIRCLE + around the ball, never the polygon seeded beside it, + and always renderable because section 2.3 gives it a + centre for the no-ball case. Drawn with a viewBox of + 0 0 100 100 and preserveAspectRatio="none", which is + exactly normalized render space: modelToRender puts + the centre in it and the radius is the same number on + both axes, so the shape on screen is the same locus + pointInCircle counts inside, in either orientation. + Non-scaling stroke keeps the line an even width + despite the non-uniform scale. */} + {ringZone && ringRow && ( + + {(() => { + const c = modelToRender(ringZone.centre, orientation); + const active = activeZoneKey === ringZone.zoneKey; + const geom = { + cx: c.left, + cy: c.top, + rx: ringZone.radius, + ry: ringZone.radius, + vectorEffect: "non-scaling-stroke" as const, + }; + return ( + <> + + {/* The only part of the ring that takes a tap: + a transparent band along the line itself, + pointer-events: stroke, so the ring's large + interior passes every tap through to the + polygon zone underneath it. */} + handleZoneClick(ringZone.zoneKey)} + onKeyDown={(e) => { + if (e.key !== "Enter" && e.key !== " ") return; + e.preventDefault(); + handleZoneClick(ringZone.zoneKey); + }} + /> + + ); + })()} + + )} + + {selected.rondo_zones.map((z) => { + // Five zones anchor their chip on their polygon. The + // ring anchors on its centre, so the chip travels + // with it exactly as the circle does. + const isRing = z.zone_key === COUNTERPRESS_RING_ZONE_KEY; + const centre = isRing ? ringCentrePoint : polygonCentroid(z.polygon); + if (!centre) return null; + if (isRing && !ringZone) return null; + const r = modelToRender(centre, orientation); + const count = countByZone.get(z.zone_key) ?? null; + // The SEEDED fallback, straight off the wire. It is + // never derived from anything on screen and is only + // ever shown when no opposition is placed. + const seeded = z.canonical_rondo ?? ""; + if (!count && !seeded) return null; + const kind = count?.superiorityKind; + return ( + + + {count ? count.label : seeded} + + {/* Doc 06 section 5.4: on phone the chip shrinks + to the ratio only and the read moves into the + tapped zone card. */} + {!phone && count && kind && ( + {kind} + )} + + ); + })}
-

{activeZone.rondo_name}

-

{activeZone.teaches}

-

Trains

-
- {activeZone.trains_pattern_codes.map((code) => ( - - {libraryNames[code] ? `${code}: ${libraryNames[code]}` : code} - + )} + + {/* Positional grid (doc 06 section 5.2). Five lanes and five + lines, plus a tint on any band the occupancy guidelines + raise a CHECK about. Lane boundaries are constant model y + and stay vertical in both orientations (portrait maps + left = y); line boundaries are constant model x and are + horizontal, flipped by top = 100 - x in portrait. */} + {overlay === "grid" && ( +
+ {laneBoundaries().map((y) => ( + + ))} + {lineBoundaries().map((x) => ( + ))} + {gridResult.breaches.map((b, i) => { + const lane = JDP_GRID.lanes.find((l) => l.key === b.cell); + const line = JDP_GRID.lines.find((l) => l.key === b.cell); + if (lane) { + return ( + + ); + } + if (!line) return null; + const top = orientation === "portrait" ? 100 - line.max : line.min; + return ( + + ); + })}
-
- )} + )} - {detailsOpen && !rondoOpen && ( -
-
-

{selected.name}

- + {/* ---------------- Floating cards ---------------- */} + + {activeKeystone && overlay !== "rondo" && ( +
+
+

Keystone

+ +
+

{activeKeystone.title}

+

{activeKeystone.blurb}

-
-

Shape

-

{selected.shape_blurb}

- -

Strengths

-
    - {selected.strengths.map((s, i) => ( -
  • {s}
  • - ))} -
+ )} -

Danger areas conceded

-
    - {selected.vulnerabilities.map((s, i) => ( -
  • {s}
  • - ))} -
- -

Keystones

-
- {selected.keystones.map((k) => ( -
+
+

Rondo

+ +
+

+ {rondoDisplayName(activeZone.rondo_name)} +

+ + {/* Doc 06 section 2.3: the ring is the one zone that is + not a place on the pitch. Saying so is the teaching + point, so the card says it before it says anything + about counts. */} + {activeZone.zone_key === COUNTERPRESS_RING_ZONE_KEY && activeZone.radius !== null && ( +

+ This zone moves. It is a circle of radius {activeZone.radius} around the ball at the moment + you lose it, so rest defence is read relative to the ball and not to the pitch. With no ball + on the board it centres on your three most advanced players, which is why it travels every + time the shape does. +

+ )} + + {(() => { + const count = countByZone.get(activeZone.zone_key); + if (!count) { + const seeded = activeZone.canonical_rondo ?? ""; + return ( +

+ {seeded ? `Canonical rondo here: ${seeded}. ` : ""} + That is the rondo this zone is coached as, not a count of what is on the board. Turn the + opposition on to see the live ratio. +

+ ); + } + const fm = freeManInZone(activeZone.zone_key); + return ( +

-

{k.title}

-

{k.blurb}

-
+ {zoneReadLine(count, fm ? fm.whyItMatters : null)} +

+ ); + })()} + +

{activeZone.teaches}

+

Trains

+
+ {activeZone.trains_pattern_codes.map((code) => ( + + {libraryNames[code] ? `${code}: ${libraryNames[code]}` : code} + ))}
-
- )} + )} + + {/* ---------------- Panels ---------------- */} + + {panel === "details" && ( +
+
+

{selected.name}

+ +
+
+

Shape

+

{selected.shape_blurb}

+ +

Strengths

+
    + {selected.strengths.map((s, i) => ( +
  • {s}
  • + ))} +
+ +

Danger areas conceded

+
    + {selected.vulnerabilities.map((s, i) => ( +
  • {s}
  • + ))} +
+ +

Keystones

+
+ {selected.keystones.map((k) => ( +
+

{k.title}

+

{k.blurb}

+
+ ))} +
+
+
+ )} + + {panel === "phase" && ( +
+ setPanel(null)} testId="formations-phase-close" /> +
+
+ {PHASE_SEGMENTS.map((seg) => { + const available = seg.key === "base" || variantsForPhase(ourPhases, seg.key).length > 0; + return ( + + ); + })} +
+ {phaseKey !== "base" && phaseVariants.length > 1 && ( +
+ {phaseVariants.map((v) => ( + + ))} +
+ )} + {activeVariant &&

{activeVariant.blurb}

} +
+
+ )} + + {panel === "opposition" && ( +
+ setPanel(null)} + testId="formations-opposition-close" + /> +
+ + + {oppositionOn && ( + <> + + + + + {read && ( +
+ {!seededMatchup && ( +

+ {NO_SEEDED_MATCHUP_NOTE} +

+ )} + +

Where you are spare

+

+ {spareLine(read, zoneNameOf) ?? + "No zone is yours on bodies or on placement right now. Move someone before you commit the ball."} +

+ +

Where you are short

+

+ {shortLine(read, zoneNameOf) ?? + "No zone is theirs on bodies right now, so there is nothing to play away from."} +

+ +

How the ball gets there

+

+ {read.routeInferred ? inferredRouteLine(read) : (seededMatchup?.route ?? "")} +

+ + {seededMatchup && ( + <> +

Our edges

+
    + {seededMatchup.our_edges.map((e, i) => ( +
  • {e}
  • + ))} +
+

Their edges

+
    + {seededMatchup.their_edges.map((e, i) => ( +
  • {e}
  • + ))} +
+ + )} +
+ )} + + )} +
+
+ )} + + {panel === "rotations" && ( +
+ chooseOverlay("rotations")} + testId="formations-rotations-close" + /> +
+ {rotations.length === 0 ? ( +

+ No rotation systems apply to this formation yet. +

+ ) : ( +
+ {rotations.map((r) => ( + + ))} +
+ )} + + {activeRotation && ( +
+

{activeRotation.name}

+

+ Playing the movement on its own, not on the full eleven, so you can see who goes where. +

+ +

Trigger

+

{activeRotation.trigger}

+ +

What moves

+
    + {activeRotation.what_moves.map((m, i) => ( +
  • + {slotLabel(m.slot)} + {m.becomes ? ` becomes the ${slotLabel(m.becomes, false)}` : ""} +
  • + ))} +
+ +

Coaching points

+
    + {activeRotation.coaching_points.map((p, i) => ( +
  • {p}
  • + ))} +
+ + {/* Doc 06 section 5.1: the risk line gets EQUAL visual + weight to the benefit. Same grid track, same + padding, same type scale, same colour weight. It is + not a footnote and the CSS must not let it become + one. */} +
+
+

Gain

+

+ Produces {activeRotation.produces_shape}. +

+
+
+

Risk

+

{activeRotation.risk}

+
+
+ + {activeRotation.exemplar_note && ( + <> +

Seen in

+

{activeRotation.exemplar_note}

+ + )} +
+ )} +
+
+ )} + + {panel === "grid" && ( +
+ chooseOverlay("grid")} + testId="formations-grid-close" + /> +
+

Positional superiority check

+ {gridResult.breaches.length === 0 ? ( +

{NO_BREACH_CHECK}

+ ) : ( +
    + {gridResult.breaches.map((b, i) => ( +
  • + {breachCheck(b)} +
  • + ))} +
+ )} +

+ These are checks, not errors. A breach can be exactly the overload you wanted, and it is + normal for one to appear and disappear as the shape moves through a phase. +

+
+
+ )} +
+ {/* end .formations-board-frame */} + +

+ {caption} +

-
+ {/* Page-level swipe-up sheet: "Browse formations" (T-106) plus + T-107's personnel panel as the segment alongside it (doc 06 + section 5.3: "opens from the sheet as a third segment"). */} +
- ))} - {tiles.length === 0 && ( -

- No matches. Try a different search. -

- )} +
+ +
+ + {sheetTab === "browse" ? ( + <> + setSearchQuery(e.target.value)} + /> + +
+ {tiles.map((f) => ( + + ))} + {tiles.length === 0 && ( +

+ No matches. Try a different search. +

+ )} +
+ + ) : ( + // key={selected.code}: a fresh formation is a fresh eleven + // slots, so the panel remounts with clean scratch state + // rather than carrying stale slot assignments from the + // formation the coach was just looking at. + + )}
)}
@@ -394,3 +1610,488 @@ export function FormationsPage({ orientation }: FormationsPageProps) { ); } + +// --------------------------------------------------------------------------- +// T-107 Personnel panel (doc 06 sections 2.6, 2.7, 5.3, 5.4). Opens as the +// sheet's "Personnel" segment: for each of the formation's eleven slots, a +// player picker (from the team roster, open to both roles), an archetype +// picker (library world, open to both roles), a top-three suggestion list +// with the server's own cited reason (COACH-ONLY), and a footedness note +// (COACH-ONLY, computed here per doc 06 section 2.7 since no endpoint +// carries it). A live unit balance read (COACH-ONLY) sits underneath. +// +// Nothing here is saved: doc 06 section 5.3 never asks the panel to +// persist a setup, and POST /formations/{code}/balance's own docstring +// says it evaluates the panel's UNSAVED picks. `assignments` is therefore +// plain component state, gone the moment the sheet's formation changes +// (the `key={selected.code}` at the call site remounts this component +// rather than carrying stale picks across formations). +// --------------------------------------------------------------------------- + +/** A formation slot once its slot_family is known non-null. Always true + * for `selected.positions` (the base formation, backend/app/routers/ + * formations.py always populates it there); the type stays a filter + * rather than an assertion because FormationPositionWire's slot_family is + * optional for the ONE other place that type is reused, a + * formation_phases row's positions, which this panel never receives. */ +type PersonnelSlotWire = FormationPositionWire & { slot_family: string }; + +interface SlotAssignment { + playerId: number | null; + archetypeCode: string | null; +} + +interface PersonnelPanelProps { + formationCode: string; + positions: FormationPositionWire[]; + role: Role; + phone: boolean; +} + +function PersonnelPanel({ formationCode, positions, role, phone }: PersonnelPanelProps) { + const isCoach = role === "coach"; + + const knownSlots = useMemo( + () => positions.filter((p): p is PersonnelSlotWire => p.slot_family !== null), + [positions] + ); + + const [roster, setRoster] = useState(null); + const [rosterError, setRosterError] = useState(null); + const [archetypesByFamily, setArchetypesByFamily] = useState>({}); + const [assignments, setAssignments] = useState>({}); + const [suggestionsBySlot, setSuggestionsBySlot] = useState>({}); + const [balance, setBalance] = useState(null); + const [groupIndex, setGroupIndex] = useState(0); + + // Roster: open to both roles (rosterApi.fetchRoster, same call the + // Roster page makes; the roster itself is player-viewable per the + // permission table, only its fit-warning analysis is coach-only, and + // this panel never touches that). + useEffect(() => { + let cancelled = false; + fetchRoster() + .then((r) => { + if (!cancelled) setRoster(r.players); + }) + .catch(() => { + if (!cancelled) setRosterError("Could not load the roster. Archetypes still work on their own."); + }); + return () => { + cancelled = true; + }; + }, []); + + // Archetype catalog per slot family found on this formation: library + // world, open to both roles. + const families = useMemo(() => [...new Set(knownSlots.map((s) => s.slot_family))], [knownSlots]); + + useEffect(() => { + let cancelled = false; + Promise.all(families.map((f) => listArchetypes(f).then((list) => [f, list] as const))) + .then((entries) => { + if (!cancelled) setArchetypesByFamily(Object.fromEntries(entries)); + }) + .catch(() => { + // Non-fatal: the roster and slot list already render either way, + // an archetype picker just comes up short of options. + }); + return () => { + cancelled = true; + }; + }, [families]); + + function setPlayerForSlot(slot: string, playerId: number | null) { + setAssignments((prev) => ({ + ...prev, + [slot]: { playerId, archetypeCode: prev[slot]?.archetypeCode ?? null }, + })); + } + + function setArchetypeForSlot(slot: string, archetypeCode: string | null) { + setAssignments((prev) => ({ + ...prev, + [slot]: { playerId: prev[slot]?.playerId ?? null, archetypeCode }, + })); + } + + // Suggestions (doc 06 section 5.3), COACH-ONLY. Keyed off the PLAYER + // each slot carries, not the archetype: which archetype is currently + // picked plays no part in ranking candidates for the slot. + const assignedPlayerKey = useMemo( + () => knownSlots.map((s) => `${s.slot}:${assignments[s.slot]?.playerId ?? ""}`).join("|"), + [knownSlots, assignments] + ); + + useEffect(() => { + if (!isCoach) { + setSuggestionsBySlot({}); + return; + } + let cancelled = false; + Promise.all( + knownSlots.map((s) => + suggestArchetypes({ + slotFamily: s.slot_family, + playerId: assignments[s.slot]?.playerId ?? null, + side: sideOfY(s.y), + }).then((res) => [s.slot, res.suggestions] as const) + ) + ) + .then((entries) => { + if (!cancelled) setSuggestionsBySlot(Object.fromEntries(entries)); + }) + .catch(() => { + if (!cancelled) setSuggestionsBySlot({}); + }); + return () => { + cancelled = true; + }; + // assignedPlayerKey stands in for `assignments` here on purpose: this + // effect only cares about WHICH PLAYER is on each slot, and keying on + // the whole assignments object would refire it on every archetype pick + // too, tripling the coach-only request count for no reason. The + // closure still reads the live `assignments` inside the effect body + // (via the .map above), so this is a deliberately narrower dependency + // list, not a stale one: assignedPlayerKey changes exactly when the + // relevant part of `assignments` does. + }, [isCoach, formationCode, assignedPlayerKey, knownSlots]); + + // Unit balance (doc 06 sections 2.6, 3.1, 5.3), COACH-ONLY, "live as + // archetypes change": keyed off the ARCHETYPE each slot carries. + const assignedArchetypeKey = useMemo( + () => knownSlots.map((s) => `${s.slot}:${assignments[s.slot]?.archetypeCode ?? ""}`).join("|"), + [knownSlots, assignments] + ); + + useEffect(() => { + if (!isCoach) { + setBalance(null); + return; + } + let cancelled = false; + evaluateUnitBalance( + formationCode, + knownSlots.map((s) => ({ slot: s.slot, archetype_code: assignments[s.slot]?.archetypeCode ?? null })) + ) + .then((res) => { + if (!cancelled) setBalance(res); + }) + .catch(() => { + if (!cancelled) setBalance(null); + }); + return () => { + cancelled = true; + }; + // Same narrower-dependency reasoning as the suggestions effect above: + // assignedArchetypeKey is what this effect actually needs to react to. + }, [isCoach, formationCode, assignedArchetypeKey, knownSlots]); + + const playersById = useMemo(() => { + const map = new Map(); + for (const p of roster ?? []) map.set(p.id, p); + return map; + }, [roster]); + + function footFor(slot: string): PreferredFootOrNull { + const playerId = assignments[slot]?.playerId ?? null; + if (playerId === null) return null; + return playersById.get(playerId)?.preferred_foot ?? null; + } + + // Rule 4 is the one footedness rule that reads TWO slots (doc 06 section + // 2.7 item 4), computed once per formation and attached under both + // fullback rows rather than derived per slot. + const fbLeftSlot = knownSlots.find((s) => s.slot_family === "fb" && sideOfY(s.y) === "left"); + const fbRightSlot = knownSlots.find((s) => s.slot_family === "fb" && sideOfY(s.y) === "right"); + const fbPairNote = fullbackPairNote( + fbLeftSlot ? footFor(fbLeftSlot.slot) : null, + fbRightSlot ? footFor(fbRightSlot.slot) : null + ); + + const groups = useMemo(() => personnelGroups(knownSlots), [knownSlots]); + + useEffect(() => { + setGroupIndex(0); + }, [formationCode]); + + if (groups.length === 0) { + return ( +

+ This formation has no slots to assign yet. +

+ ); + } + + const clampedIndex = Math.min(groupIndex, groups.length - 1); + const visibleGroups = phone ? [groups[clampedIndex]] : groups; + + return ( +
+ {rosterError && ( +

+ {rosterError} +

+ )} + + {/* Empty roster is a first-class state (doc 06 section 5.3): the + panel still works, archetypes alone, no players assigned. This + notice is informational for both roles, not a coach-only read. */} + {roster !== null && roster.length === 0 && ( +

+ No players on the roster yet. Archetypes still rank and the shape still holds together on its own; assign + players whenever the roster is ready. +

+ )} + + {/* doc 06 section 5.4: "the personnel panel is a full-height sheet, + ONE UNIT AT A TIME" on phone. Desktop stacks every group and + scrolls the panel instead. */} + {phone && ( +
+ + + {groups[clampedIndex].label} ({clampedIndex + 1} of {groups.length}) + + +
+ )} + + {visibleGroups.map((group) => ( +
+ {!phone &&

{group.label}

} + {group.slots.map((slot) => ( + setPlayerForSlot(slot.slot, playerId)} + onArchetypeChange={(code) => setArchetypeForSlot(slot.slot, code)} + suggestions={suggestionsBySlot[slot.slot]} + footNote={slot.slot_family === "fb" ? fbPairNote : slotFootednessNote(slot.slot_family, sideOfY(slot.y), footFor(slot.slot))} + coachView={isCoach} + /> + ))} +
+ ))} + + {/* Unit balance (doc 06 sections 2.6, 3.1, 5.3), COACH-ONLY: absent + from the DOM entirely for a player token, not merely hidden, + because `balance` never leaves null when isCoach is false. Reads + the REAL slot-to-unit crosswalk straight off the balance + response (app/units.py), so it names units the simple four-group + pager above never had to reconstruct (e.g. "Wide unit, left"). + A unit the formation does not contain is not in `units` at all + (T-110's units_not_evaluated), so nothing here mentions it: say + nothing rather than shout. */} + {isCoach && balance && ( +
+

Unit balance

+ {balance.units.length === 0 ? ( +

+ No units are ready to read yet. Pick archetypes for a full unit (a back line, a midfield trio or pivot, + a front line) to see a balance read. +

+ ) : ( + balance.units.map((u) => ( +
+

{unitHeading(u.unit, u.flank)}

+ {u.notes.length === 0 ? ( +

+ {u.is_complete + ? "No balance notes. This unit checks out." + : "Not fully assigned yet. Balance notes appear once every slot in this unit has an archetype."} +

+ ) : ( + u.notes.map((n) => ( +

+ {n.message} +

+ )) + )} +
+ )) + )} +
+ )} +
+ ); +} + +type PreferredFootOrNull = PlayerWire["preferred_foot"] | null; + +interface SlotRowProps { + slot: PersonnelSlotWire; + side: ReturnType; + archetypes: PositionArchetypeWire[]; + roster: PlayerWire[]; + assignment: SlotAssignment; + onPlayerChange: (playerId: number | null) => void; + onArchetypeChange: (code: string | null) => void; + /** undefined (not yet loaded) and [] (loaded, empty) both render no + * list; the distinction only matters to the network layer above. */ + suggestions: ArchetypeSuggestionWire[] | undefined; + footNote: string | null; + /** Suggestions and the footedness note are COACH-ONLY (doc 06 sections + * 2.7, 5.3): when false, this row renders neither, and not as a hidden + * element either, so a player token's page carries no trace of them. */ + coachView: boolean; +} + +function SlotRow({ + slot, + side, + archetypes, + roster, + assignment, + onPlayerChange, + onArchetypeChange, + suggestions, + footNote, + coachView, +}: SlotRowProps) { + const selectedArchetype = archetypes.find((a) => a.code === assignment.archetypeCode) ?? null; + return ( +
+
+ + {slotLabel(slot.slot)} + + + {familyLabel(slot.slot_family)} + {side !== "center" ? `, ${side}` : ""} + +
+ + + + + {selectedArchetype && ( +

+ {selectedArchetype.definition} +

+ )} + + {/* Suggestions (doc 06 section 5.3): "show the top three with a + one-line why for each. The why must cite the actual reason ..., + not a score." `s.why` is rendered exactly as the API returns it; + nothing here recomputes or summarises it. */} + {coachView && suggestions && suggestions.length > 0 && ( +
+

Suggested archetypes

+
    + {suggestions.map((s) => ( +
  • + + + {s.why} + +
  • + ))} +
+
+ )} + + {coachView && footNote && ( +

+ {footNote} +

+ )} +
+ ); +} + +function PanelHead({ + title, + onClose, + testId, +}: { + title: string; + onClose: () => void; + testId: string; +}) { + return ( +
+

{title}

+ +
+ ); +} + +function phaseLabel(key: PhaseKey): string { + return PHASE_SEGMENTS.find((s) => s.key === key)?.label ?? key; +} diff --git a/frontend/src/pages/IdentityPage.css b/frontend/src/pages/IdentityPage.css index 7533726..99d7c6c 100644 --- a/frontend/src/pages/IdentityPage.css +++ b/frontend/src/pages/IdentityPage.css @@ -3,8 +3,10 @@ wholesale since both pages share the same board-first + swipe-up-sheet shape (design README sections 3/5); this file only adds what's specific to the identity content itself. Colors come only from the theme token - variables; gold is the only interactive color, red never appears here - as a call to action (the pass-risk "Off-menu" label uses --text-red as + variables (T-071 founder directive): var(--accent) is the brand red and + the only interactive colour, var(--warn) is shield gold for read-only + status, and red never appears here as a call to action (the pass-risk + "Off-menu" label uses --text-red as a STATUS color, same convention as a fit warning or a blocked lane, never as a clickable control). */ @@ -60,7 +62,7 @@ padding: 3px 9px; border-radius: 999px; background: var(--bg); - color: var(--accent); + color: var(--text-warn); } .identity-keystone-list { @@ -71,16 +73,17 @@ gap: 4px; } -/* Pass-risk block (Bible 5.7, style archetypes only): Encouraged in gold - (matches the confirmed-lane / interactive convention), Off-menu in the - status red used elsewhere for blocked lanes and fit warnings (never a - clickable control, so this is not "red as a CTA"), Tempo muted italic. */ +/* Pass-risk block (Bible 5.7, style archetypes only): Encouraged in shield + gold (--warn, matching the board's confirmed-lane language and the rest of + this app's read-only status), Off-menu in the status red, Tempo muted + italic. Neither is clickable, so neither takes the interactive brand red + (T-071). */ .identity-risk-encouraged { - color: var(--accent); + color: var(--text-warn); font-weight: 600; } .identity-risk-discouraged { - color: var(--text-red, var(--red)); + color: var(--text-red); font-weight: 600; } .identity-risk-tempo { diff --git a/frontend/src/pages/PatternsPage.css b/frontend/src/pages/PatternsPage.css index 7426eb5..9b3fe98 100644 --- a/frontend/src/pages/PatternsPage.css +++ b/frontend/src/pages/PatternsPage.css @@ -1,7 +1,9 @@ /* Patterns page (Brief step 17, PNG 05-10, 29-31, 15-18, 35). Colors come - only from the theme token variables; gold (var(--accent)/var(--glow)) is - the only interactive/active color, red never appears here as a call to - action. */ + only from the theme token variables (T-071 founder directive): + var(--accent) is the brand red and the only interactive/active colour, + var(--warn) is shield gold for labels and non-interactive status, and + anything drawn on a pitch (the mini thumbnails) reads the BOARD token + layer so the chrome's red can never repaint turf or a team. */ .patterns-page { max-width: 1100px; @@ -14,8 +16,14 @@ color: var(--text-secondary); margin: 0 0 8px; } +/* Failure status: red as text inside a tinted, outlined block, a shape the + brand-red accent never takes (T-071). */ .patterns-error { - color: var(--text-red, var(--red)); + padding: 6px 10px; + border: 1px solid var(--red); + border-radius: 8px; + background: var(--bg-red); + color: var(--text-red); } .patterns-error-overlay { position: absolute; @@ -65,10 +73,11 @@ overflow: hidden; text-overflow: ellipsis; } +/* A label, not a control: shield gold. */ .patterns-meta-author { font-size: 11px; letter-spacing: 0.05em; - color: var(--accent); + color: var(--text-warn); white-space: nowrap; } .patterns-details-btn { @@ -83,8 +92,8 @@ } /* Playing indicator (PNG 05, 09): top-right over the pitch, echoing the - ball's own gold glow (design README token table). Never red: red is - status/danger only, never used for a positive "in progress" state. */ + ball's own gold. Nothing here is clickable, so it takes the shield gold + --warn family rather than the interactive brand red (T-071). */ .patterns-playing-pill { position: absolute; top: 10px; @@ -96,7 +105,7 @@ padding: 5px 12px; border-radius: 999px; background: var(--surface); - color: var(--accent); + color: var(--text-warn); font-family: var(--body-font); font-size: 12px; white-space: nowrap; @@ -105,8 +114,8 @@ width: 8px; height: 8px; border-radius: 50%; - background: var(--glow); - box-shadow: 0 0 6px var(--glow); + background: var(--warn); + box-shadow: 0 0 6px var(--warn); } /* Details panel (PNG 10, 18, 31): a right-hand rail on desktop, per library @@ -194,6 +203,7 @@ align-items: flex-start; gap: 8px; } +/* Step numbers are read, not pressed: gold, not the interactive red. */ .patterns-details-step-n { flex-shrink: 0; display: inline-flex; @@ -202,8 +212,8 @@ width: 20px; height: 20px; border-radius: 50%; - background: var(--accent); - color: var(--accent-ink, #1b1b1b); + background: var(--warn); + color: var(--on-warn); font-size: 11px; font-weight: 600; } @@ -377,7 +387,7 @@ font-size: 10px; letter-spacing: 0.05em; text-transform: uppercase; - color: var(--accent); + color: var(--text-warn); } .patterns-tile-name { font-size: 13px; @@ -395,20 +405,23 @@ border-radius: 4px; display: block; } +/* Mini board thumbnails are a PITCH, not chrome: turf, teams, and ball all + read BOARD tokens, so they stay a green pitch with a gold and a red team + whatever the chrome accent is (T-071). */ .tile-thumb-bg { - fill: var(--bg-stripe); + fill: var(--pitch-turf); } .tile-thumb-token { stroke: none; } .tile-thumb-home { - fill: var(--accent); + fill: var(--team-home); } .tile-thumb-away { - fill: var(--red); + fill: var(--team-away); } .tile-thumb-ball { - fill: var(--glow); + fill: var(--ball); } @media (max-width: 700px) { diff --git a/frontend/src/pages/RosterPage.css b/frontend/src/pages/RosterPage.css index 50a2072..bca78ee 100644 --- a/frontend/src/pages/RosterPage.css +++ b/frontend/src/pages/RosterPage.css @@ -1,7 +1,10 @@ -/* Roster page (PNG 12 desktop / 20 phone). Tokens only, no hardcoded - colors: red is status-only here (the FIT banner), never a call to - action; gold is the only interactive color (the active row's border, - the Save/Add-player buttons, the slider thumbs via accent-color). */ +/* Roster page (PNG 12 desktop / 20 phone). Tokens only, no hardcoded colors + (T-071 founder directive): var(--accent) is the brand red and the only + interactive colour (the active row's border, the Save/Add-player buttons, + the slider thumbs via accent-color), and var(--warn) is shield gold for + coach advisories and status. The FIT banner is the reason the split + exists: it is a warning, so it wears gold. If it stayed red it would be + indistinguishable from a primary button now that the brand is red. */ .roster-page { max-width: 1200px; @@ -13,18 +16,24 @@ font-size: 13px; margin: 0 0 12px; } +/* Failure status: red text in a tinted, outlined block, never a red fill. */ .roster-error { - color: var(--text-red, var(--red)); + padding: 6px 10px; + border: 1px solid var(--red); + border-radius: 8px; + background: var(--bg-red); + color: var(--text-red); } -/* FIT warning banner: red border/tag, coach-only (see RosterPage.tsx). - Status only, never clickable, so it carries no interactive styling. */ +/* FIT warning banner: gold border/tag, coach-only (see RosterPage.tsx). + Advisory only, never clickable, so it carries no interactive styling and + never the brand red (T-071). */ .fit-warning { display: flex; align-items: flex-start; gap: 12px; - border: 1px solid var(--red); - background: var(--bg-red, transparent); + border: 1px solid var(--warn); + background: var(--bg-warn); border-radius: var(--radius); padding: 12px 14px; margin-bottom: 14px; @@ -36,8 +45,8 @@ font-size: 11px; font-weight: 600; letter-spacing: 0.06em; - color: var(--text-red, var(--red)); - border: 1px solid var(--red); + color: var(--text-warn); + border: 1px solid var(--warn); border-radius: 4px; padding: 2px 6px; } @@ -47,7 +56,7 @@ line-height: 1.45; } .fit-warning-body strong { - color: var(--text-red, var(--red)); + color: var(--text-warn); font-weight: 600; } .fit-warning-body p { @@ -117,16 +126,16 @@ } /* Coach-only "gold badge on the row" for a pending playstyle suggestion - (README roles table; Brief step 22). Gold is the only interactive - color, but this is a status dot, not a control; kept small and - inline like .roster-you rather than a full pill. */ + (README roles table; Brief step 22). This is a status dot, not a control, + so it stays literally gold (--warn) rather than following the interactive + colour to red. Kept small and inline like .roster-you, not a full pill. */ .suggestion-badge { display: inline-block; width: 7px; height: 7px; margin-left: 6px; border-radius: 50%; - background: var(--accent); + background: var(--warn); vertical-align: middle; } .roster-row-role { @@ -339,9 +348,9 @@ } /* Playstyle suggestion flow (PNG 24/25/27; Brief step 22): the composer, - the player's own pending card, and the coach's review card all share - this gold-bordered card shape, gold being the design system's one - interactive/highlight color. */ + the player's own pending card, and the coach's review card all share one + card shape outlined in the interactive colour, because each of the three + holds a control (submit, or approve/dismiss). */ .suggestion-card { border: 1px solid var(--accent); border-radius: var(--radius); diff --git a/frontend/src/pages/SessionsPage.css b/frontend/src/pages/SessionsPage.css index 51db100..1fb1e79 100644 --- a/frontend/src/pages/SessionsPage.css +++ b/frontend/src/pages/SessionsPage.css @@ -1,8 +1,9 @@ /* Sessions page (Brief step 23, PNG 21-23, 26, 28). Colors come only from - the theme token variables: gold (var(--accent)) is the only interactive - colour (SENT pill, Send to players, Mark as watched, Viewed state), and - red never appears here at all, since nothing on this screen is a danger - state. */ + the theme token variables (T-071 founder directive). Two families here: + var(--accent), the brand red, on the controls (Send to players, Mark as + watched, the active rail row), and var(--warn), shield gold, on every + read-only state (the SENT pill, the Viewed receipt, the watched pill, + item kickers). Nothing gold is clickable and nothing red is a status. */ .sessions-page { max-width: 1180px; @@ -15,8 +16,13 @@ color: var(--text-secondary); margin: 0 0 8px; } +/* Failure status: red text in a tinted, outlined block, never a red fill. */ .sessions-error { - color: var(--text-red, var(--red)); + padding: 6px 10px; + border: 1px solid var(--red); + border-radius: 8px; + background: var(--bg-red); + color: var(--text-red); } /* Rail + detail (PNG 21/22). Stacks to one column on phone (design README: @@ -180,15 +186,17 @@ flex-wrap: wrap; } -/* SENT pill with the x/y viewed counter (PNG 21). */ +/* SENT pill with the x/y viewed counter (PNG 21). A state, not a control: + shield gold, so it stays the gold pill the PNG shows and never competes + with a red primary button (T-071). */ .sessions-sent-pill { display: inline-flex; align-items: center; gap: 6px; padding: 5px 12px; border-radius: 999px; - background: var(--accent); - color: var(--accent-ink, #1b1b1b); + background: var(--warn); + color: var(--on-warn); font-family: var(--display-font); font-size: 11px; letter-spacing: 0.08em; @@ -211,8 +219,8 @@ white-space: nowrap; } .sessions-watched-pill { - border-color: var(--accent); - color: var(--accent); + border-color: var(--warn); + color: var(--text-warn); } .sessions-kicker { @@ -290,7 +298,7 @@ font-size: 10px; letter-spacing: 0.05em; text-transform: uppercase; - color: var(--accent); + color: var(--text-warn); } .sessions-item-name { font-family: var(--body-font); @@ -433,6 +441,7 @@ border: 1px solid var(--border); background: var(--bg); } +/* Receipts are coach-only reading, never controls: gold. */ .sessions-receipt-badge { flex-shrink: 0; display: inline-flex; @@ -441,8 +450,8 @@ width: 26px; height: 26px; border-radius: 50%; - border: 1px solid var(--accent); - color: var(--accent); + border: 1px solid var(--warn); + color: var(--text-warn); font-family: var(--display-font); font-size: 12px; } @@ -466,8 +475,8 @@ white-space: nowrap; } .sessions-receipt-viewed { - border-color: var(--accent); - color: var(--accent); + border-color: var(--warn); + color: var(--text-warn); } /* Watch view: the item playing full width on the board, portrait on phone diff --git a/frontend/src/pages/WhiteboardPage.css b/frontend/src/pages/WhiteboardPage.css index 3fca7b5..4305a61 100644 --- a/frontend/src/pages/WhiteboardPage.css +++ b/frontend/src/pages/WhiteboardPage.css @@ -11,8 +11,14 @@ margin: 0 0 8px; } +/* Failure status: red text in a tinted, outlined block, a shape the brand + red accent never takes (T-071). */ .whiteboard-error { font-family: var(--body-font); font-size: 13px; - color: var(--text-red, var(--red)); + padding: 6px 10px; + border: 1px solid var(--red); + border-radius: 8px; + background: var(--bg-red); + color: var(--text-red); } diff --git a/frontend/src/pages/formationsLab.test.ts b/frontend/src/pages/formationsLab.test.ts new file mode 100644 index 0000000..f72b428 --- /dev/null +++ b/frontend/src/pages/formationsLab.test.ts @@ -0,0 +1,339 @@ +// Unit tests for the Tactics Lab's copy and naming helpers (T-106, doc 06 +// sections 2.1, 2.2, 2.8, 5.1, 5.2). These are the sentences a coach reads, +// so they get asserted like content, not like plumbing: the ratio must +// leave the display name, a breach must never read as an error, and every +// read line must name which superiority it is talking about. + +import { describe, expect, it } from "vitest"; +import { + breachCheck, + defaultOpponentVariant, + inferredRouteLine, + laneBoundaries, + lineBoundaries, + NO_BREACH_CHECK, + NO_SEEDED_MATCHUP_NOTE, + polygonCentroid, + ringCentre, + rondoDisplayName, + shortLine, + slotLabel, + spareLine, + variantsForPhase, + zoneReadLine, +} from "./formationsLab"; +import { BALL_FALLBACK_ADVANCED_COUNT } from "../board/superiority"; +import type { GridBreach, MatchupRead, SlotPos, ZoneCount } from "../board/superiorityTypes"; +import type { FormationPhaseWire } from "../tacticsApi"; + +function phase(variant_code: string, phaseName: FormationPhaseWire["phase"]): FormationPhaseWire { + return { + formation_code: "433", + variant_code, + phase: phaseName, + name: variant_code, + shape_label: "3-2-5", + blurb: "", + positions: [], + trigger: "", + rest_shape: null, + reference_code: null, + uses_rotations: [], + }; +} + +function count(over: Partial = {}): ZoneCount { + return { + zoneKey: "midfield_box", + ours: 4, + theirs: 2, + delta: 2, + label: "4v2", + verdict: "superiority", + superiorityKind: "numerical", + anchorX: 50, + ...over, + }; +} + +describe("rondoDisplayName", () => { + it("takes the ratio out of the display name", () => { + expect(rondoDisplayName("5v3 (the midfield box)")).toBe("The midfield box"); + // The whole point of the epic: no ratio survives in the name a coach + // reads next to a live one. + expect(rondoDisplayName("5v3 (the midfield box)")).not.toMatch(/\dv\d/); + }); + + it("takes the LAST parenthetical, so a ratio may contain one", () => { + expect(rondoDisplayName("2v2 (+1 keeper) (the last line)")).toBe("The last line"); + }); + + it("handles a slashed ratio", () => { + expect(rondoDisplayName("4v2 / 3v2 (first-line build-up)")).toBe("First-line build-up"); + }); + + it("keeps a name with no parenthetical whole", () => { + expect(rondoDisplayName("The half-space pocket")).toBe("The half-space pocket"); + }); + + it("returns the counterpress ring's name without its ratio", () => { + expect(rondoDisplayName("4v4+3 (the counterpress ring)")).toBe("The counterpress ring"); + }); + + it("never returns a ratio, which is what canonical_rondo is for", () => { + // Regression guard on the workaround T-112 deleted. splitRondoName used + // to hand back a `seededRatio` because canonical_rondo was not on the + // wire; the chip now reads the column, and nothing here may go back to + // synthesising a ratio from a display name. + for (const seeded of [ + "4v2 / 3v2 (first-line build-up)", + "5v3 (the midfield box)", + "2v1 to 2v2 (the flank corridor)", + "2v2 (+1 keeper) (the last line)", + "4v4+3 (the counterpress ring)", + ]) { + expect(rondoDisplayName(seeded)).not.toMatch(/\dv\d/); + } + }); +}); + +describe("ringCentre (doc 06 section 2.3)", () => { + function slot(s: string, x: number, y: number): SlotPos { + return { slot: s, position_code: "cm", x, y }; + } + + // The 4-3-3's three most advanced at base (seeds/formations.json): the + // striker at x 88 and both wingers at x 76.5, which average to 80.333. + const shape: SlotPos[] = [ + slot("gk", 5, 50), + slot("cb_l", 22, 40), + slot("cb_r", 22, 60), + slot("six", 42, 50), + slot("eight_l", 58, 36), + slot("eight_r", 58, 64), + slot("w_l", 76.5, 12), + slot("w_r", 76.5, 88), + slot("st", 88, 50), + ]; + + it("centres on the centroid of our three most advanced when no ball is placed", () => { + const c = ringCentre(shape, null); + expect(c?.x).toBeCloseTo((88 + 76.5 + 76.5) / 3, 6); + expect(c?.y).toBeCloseTo((50 + 12 + 88) / 3, 6); + }); + + it("uses exactly three, the engine's own BALL_FALLBACK_ADVANCED_COUNT", () => { + expect(BALL_FALLBACK_ADVANCED_COUNT).toBe(3); + // A fourth player just behind the front three must not move the centre. + const withDeeper = [...shape, slot("am", 70, 50)]; + expect(ringCentre(withDeeper, null)).toEqual(ringCentre(shape, null)); + }); + + it("moves when the shape advances, which is the teaching point", () => { + const base = ringCentre(shape, null); + const pushedOn = ringCentre( + shape.map((s) => (s.x > 70 ? { ...s, x: s.x + 8 } : s)), + null + ); + expect(pushedOn?.x).toBeGreaterThan(base?.x ?? 0); + }); + + it("prefers a placed ball over the fallback", () => { + expect(ringCentre(shape, { x: 30, y: 20 })).toEqual({ x: 30, y: 20 }); + }); + + it("does not depend on the order the caller built the array", () => { + const reversed = [...shape].reverse(); + expect(ringCentre(reversed, null)).toEqual(ringCentre(shape, null)); + }); + + it("breaks an x tie by slot rather than by array order", () => { + // Four players on the same x: which three are picked must be decided by + // the data, not by whoever built the list. + const tied: SlotPos[] = [ + slot("d", 80, 10), + slot("a", 80, 20), + slot("c", 80, 30), + slot("b", 80, 40), + ]; + expect(ringCentre(tied, null)).toEqual(ringCentre([...tied].reverse(), null)); + // a, b, c win on slot order: y averages 20, 40, 30. + expect(ringCentre(tied, null)?.y).toBeCloseTo(30, 6); + }); + + it("has no centre when there is nobody to centre on", () => { + expect(ringCentre([], null)).toBeNull(); + }); + + it("averages fewer than three when fewer are on the pitch", () => { + expect(ringCentre([slot("a", 60, 20), slot("b", 40, 40)], null)).toEqual({ x: 50, y: 30 }); + }); +}); + +describe("phase selection", () => { + it("filters variants to one phase and keeps API order", () => { + const phases = [ + phase("in_possession", "in_possession"), + phase("in_possession_alt", "in_possession"), + phase("out_of_possession", "out_of_possession"), + ]; + expect(variantsForPhase(phases, "in_possession").map((p) => p.variant_code)).toEqual([ + "in_possession", + "in_possession_alt", + ]); + expect(variantsForPhase(phases, "base")).toEqual([]); + expect(variantsForPhase(phases, "rest_defence")).toEqual([]); + }); + + it("defaults the opponent picker to an out-of-possession variant", () => { + const phases = [phase("in_possession", "in_possession"), phase("mid_block", "out_of_possession")]; + expect(defaultOpponentVariant(phases)).toBe("mid_block"); + }); + + it("falls back to the first variant, then to their base shape", () => { + expect(defaultOpponentVariant([phase("only_ip", "in_possession")])).toBe("only_ip"); + expect(defaultOpponentVariant([])).toBeNull(); + }); +}); + +describe("slotLabel", () => { + it("keeps position codes upper case and expands the side suffix", () => { + expect(slotLabel("cb")).toBe("CB"); + expect(slotLabel("cb_l")).toBe("CB left"); + expect(slotLabel("wb_far")).toBe("WB far"); + expect(slotLabel("middle_cb")).toBe("Middle CB"); + expect(slotLabel("far_fullback")).toBe("Far fullback"); + }); + + it("keeps the code upper case mid-sentence while the leading word stays lower", () => { + expect(slotLabel("third_cb", false)).toBe("third CB"); + expect(slotLabel("pivot", false)).toBe("pivot"); + }); +}); + +describe("zoneReadLine", () => { + it("names numerical superiority and carries the computed label", () => { + const line = zoneReadLine(count(), null); + expect(line).toContain("Numerical superiority"); + expect(line).toContain("4v2"); + }); + + it("names numerical inferiority without calling the shape wrong", () => { + const line = zoneReadLine( + count({ ours: 2, theirs: 3, delta: -1, label: "2v3", verdict: "inferiority", superiorityKind: null }), + null + ); + expect(line).toContain("Numerical inferiority"); + expect(line.toLowerCase()).not.toContain("invalid"); + expect(line.toLowerCase()).not.toContain("wrong"); + }); + + it("promotes parity to POSITIONAL superiority when a free man is in there", () => { + const parity = count({ ours: 3, theirs: 3, delta: 0, label: "3v3", verdict: "parity", superiorityKind: "positional" }); + const line = zoneReadLine(parity, "Positional superiority: alone in the centre."); + expect(line).toContain("Parity on bodies at 3v3"); + expect(line).toContain("Positional superiority"); + }); + + it("says plainly when parity is just parity", () => { + const parity = count({ ours: 3, theirs: 3, delta: 0, label: "3v3", verdict: "parity", superiorityKind: null }); + const line = zoneReadLine(parity, null); + expect(line).toContain("Parity: 3v3"); + expect(line).toContain("no positional edge"); + }); +}); + +describe("the read", () => { + const read: MatchupRead = { + spare: count({ zoneKey: "midfield_box" }), + short: count({ + zoneKey: "last_line", + ours: 1, + theirs: 3, + delta: -2, + label: "1v3", + verdict: "inferiority", + superiorityKind: null, + }), + route: "through", + routeInferred: true, + seededCard: null, + }; + const naming = (key: string) => (key === "midfield_box" ? "the midfield box" : "the last line"); + + it("names the superiority on both steps", () => { + expect(spareLine(read, naming)).toBe("Numerical superiority in the midfield box: 4v2."); + expect(shortLine(read, naming)).toBe("Numerical inferiority in the last line: 1v3."); + }); + + it("labels an inferred route as inferred and hedges it", () => { + const line = inferredRouteLine(read); + expect(line).toContain("rather than from a coached card"); + expect(line).toContain("probably"); + }); + + it("says plainly that an unseeded pair has no coached read", () => { + expect(NO_SEEDED_MATCHUP_NOTE).toContain("no coached read yet"); + expect(NO_SEEDED_MATCHUP_NOTE).toContain("computed live"); + }); + + it("reports no spare or short zone as null rather than inventing one", () => { + const empty: MatchupRead = { spare: null, short: null, route: "over", routeInferred: true, seededCard: null }; + expect(spareLine(empty, naming)).toBeNull(); + expect(shortLine(empty, naming)).toBeNull(); + }); +}); + +describe("breachCheck", () => { + const cases: GridBreach[] = [ + { kind: "lane_over", cell: "left_half_space", count: 3, slots: ["a", "b", "c"] }, + { kind: "wide_lane_shared", cell: "right_wing", count: 2, slots: ["a", "b"] }, + { kind: "line_over", cell: "last_line", count: 4, slots: ["a", "b", "c", "d"] }, + { kind: "line_over", cell: "own_build", count: 5, slots: ["a", "b", "c", "d", "e"] }, + ]; + + it("matches doc 06 section 5.2's own example wording", () => { + expect(breachCheck(cases[0])).toBe( + "Three in the left half-space. Intentional overload, or is someone standing in a teammate's zone?" + ); + }); + + it("is always a question, never a verdict", () => { + for (const c of cases) { + const copy = breachCheck(c); + expect(copy.endsWith("?"), copy).toBe(true); + expect(copy.toLowerCase()).not.toContain("invalid"); + expect(copy.toLowerCase()).not.toContain("wrong"); + expect(copy.toLowerCase()).not.toContain("error"); + } + }); + + it("spells the count as a word so it reads as a sentence", () => { + expect(breachCheck(cases[1]).startsWith("Two ")).toBe(true); + expect(breachCheck(cases[2]).startsWith("Four ")).toBe(true); + expect(breachCheck(cases[3]).startsWith("Five ")).toBe(true); + }); + + it("names the superiority when nothing breaches", () => { + expect(NO_BREACH_CHECK).toContain("positional superiority"); + }); +}); + +describe("overlay geometry", () => { + it("draws only the interior grid boundaries", () => { + expect(laneBoundaries()).toEqual([19, 37, 63, 81]); + expect(lineBoundaries()).toEqual([22, 42, 60, 78]); + }); + + it("centres a chip on the mean vertex of its polygon", () => { + expect( + polygonCentroid([ + { x: 35, y: 15 }, + { x: 65, y: 15 }, + { x: 65, y: 85 }, + { x: 35, y: 85 }, + ]) + ).toEqual({ x: 50, y: 50 }); + expect(polygonCentroid([])).toEqual({ x: 50, y: 50 }); + }); +}); diff --git a/frontend/src/pages/formationsLab.ts b/frontend/src/pages/formationsLab.ts new file mode 100644 index 0000000..66cde60 --- /dev/null +++ b/frontend/src/pages/formationsLab.ts @@ -0,0 +1,367 @@ +// Pure helpers for the Tactics Lab Formations page (T-106, doc 06 sections +// 5.1, 5.2, 5.4). No React, no DOM, no network: everything here is a pure +// function of engine output plus seeded wire data, so the coach-facing copy +// is unit testable without mounting a board. +// +// TWO RULES THIS MODULE EXISTS TO KEEP HONEST. +// +// 1. A RATIO IS EITHER COMPUTED OR SEEDED, NEVER BOTH, AND THE TWO ARE +// NEVER SPELLED THE SAME WAY. With no opposition on the board the chip +// carries the seeded fallback and is styled muted. With opposition on it +// carries `ZoneCount.label`, which T-104's countZone derived from the +// two shapes actually on the pitch this instant. Nothing in this file +// ever writes a ratio literal. +// +// 2. EVERY CARD NAMES WHICH SUPERIORITY IT IS TALKING ABOUT (doc 06 section +// 2.1). Numerical is more bodies. Positional is the same bodies better +// placed. Qualitative is a personnel read and is NOT claimed anywhere +// here, because personnel is T-107 and the engine cannot see it yet. +// +// Breach copy is a CHECK, never an error (doc 06 section 2.2). The words +// "invalid" and "wrong" do not appear in this file and must not: a +// temporary breach is legal football. + +import { JDP_GRID } from "../board/grid"; +import { BALL_FALLBACK_ADVANCED_COUNT } from "../board/superiority"; +import type { + GridBreach, + LaneKey, + LineKey, + MatchupRead, + Pt, + SlotPos, + ZoneCount, +} from "../board/superiorityTypes"; +import type { FormationPhaseWire, PhaseName } from "../tacticsApi"; + +// --------------------------------------------------------------------------- +// The phase segment (doc 06 section 5.1 control 1) +// --------------------------------------------------------------------------- + +/** "base" is the formation's own seeded shape, which is not a + * formation_phases row: it is formations.positions_json. The other three + * are phase names with seeded variants. */ +export type PhaseKey = "base" | "in_possession" | "out_of_possession" | "rest_defence"; + +export interface PhaseSegment { + key: PhaseKey; + /** Desktop label, doc 06 section 5.1 verbatim. */ + label: string; + /** Phone icon-row label: the segment collapses, the meaning must not. */ + short: string; +} + +export const PHASE_SEGMENTS: PhaseSegment[] = [ + { key: "base", label: "Base", short: "Base" }, + { key: "in_possession", label: "With the ball", short: "Ball" }, + { key: "out_of_possession", label: "Without the ball", short: "No ball" }, + { key: "rest_defence", label: "Rest defence", short: "Rest" }, +]; + +/** Variants seeded for one phase of one formation, in the API's own order + * (T-108 sorts by phase then variant_code, so this is stable). */ +export function variantsForPhase( + phases: readonly FormationPhaseWire[], + key: PhaseKey +): FormationPhaseWire[] { + if (key === "base") return []; + return phases.filter((p) => p.phase === (key as PhaseName)); +} + +/** + * The opponent variant to open the picker on. Doc 06 section 5.1: "their + * out-of-possession variants are the ones that matter, so default the + * picker there". Falls back to the first seeded variant, then to their base + * shape (null), so a formation with no out-of-possession row still opens on + * something real rather than on an empty picker. + */ +export function defaultOpponentVariant(phases: readonly FormationPhaseWire[]): string | null { + const out = phases.find((p) => p.phase === "out_of_possession"); + if (out) return out.variant_code; + return phases[0]?.variant_code ?? null; +} + +// --------------------------------------------------------------------------- +// Rondo zone naming: getting the ratio OUT of the display name +// --------------------------------------------------------------------------- + +/** Doc 06 section 2.3's ball-relative zone. Named once, matched by key. */ +export const COUNTERPRESS_RING_ZONE_KEY = "counterpress_ring"; + +/** rondo_zones.zone_kind for a zone that is a circle around the ball rather + * than a fixed polygon (doc 06 section 2.3, migration 0006). */ +export const BALL_RELATIVE_CIRCLE = "ball_relative_circle"; + +/** + * "5v3 (the midfield box)" as the name alone: "The midfield box". + * + * The seeded rondo_name carries the canonical ratio in front of the name, + * and seeds/rondo_zones.json says out loud that taking it back out is a UI + * change rather than a seed change. This is that change. It is NOT where + * the fallback chip's ratio comes from: that is rondo_zones.canonical_rondo, + * read straight off the wire since T-112. Splitting a ratio out of a display + * name to stand in for a column was T-106's `splitRondoName` workaround and + * it is gone; nothing in this file synthesises a ratio from anything. + * + * Takes the LAST parenthetical rather than the first, because one seeded + * name carries a parenthetical inside the ratio itself + * ("2v2 (+1 keeper) (the last line)") and splitting on the first would file + * "+1 keeper" as the zone's name. A name with no parenthetical at all is + * already a plain name and is returned whole. + */ +export function rondoDisplayName(rondoName: string): string { + const idx = rondoName.lastIndexOf(" ("); + if (idx === -1 || !rondoName.endsWith(")")) return rondoName; + return capitalise(rondoName.slice(idx + 2, rondoName.length - 1).trim()); +} + +function capitalise(s: string): string { + return s.length === 0 ? s : s.charAt(0).toUpperCase() + s.slice(1); +} + +// --------------------------------------------------------------------------- +// The counterpress ring's centre (doc 06 section 2.3) +// --------------------------------------------------------------------------- + +/** + * Where the counterpress ring sits, in landscape model coords. + * + * Doc 06 section 2.3: "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." + * + * So the fallback is UNCONDITIONAL, which is what makes the ring always + * renderable. Doc 06 section 5.1 says the ring "only renders when a ball is + * placed or a phase with a defined ball position is active"; that clause + * assumes a ball affordance the Formations page does not have and + * formation_phases has no column for, and section 2.3 defines the object + * itself and hands it a centre for exactly that case. Section 2.3 wins. + * + * `ball` is threaded through rather than assumed absent because the spec + * gives it precedence and a later surface (a placed ball, a phase that + * gains a ball position) should not have to rediscover that rule. The + * Formations page passes null today: it has no ball. + * + * Most advanced means largest x, x growing toward the attacking goal + * (CLAUDE.md rule 8). Ties break by slot, so two players on the same x + * cannot make the ring depend on the order the caller built its array; the + * engine's own fallbackBallLine does not need that because it averages x + * alone, and this averages y as well. + * + * Null when there is nobody to centre on, which is the shape of "no ring" + * rather than a ring parked at the origin. + */ +export function ringCentre(ours: readonly SlotPos[], ball: Pt | null): Pt | null { + if (ball) return { x: ball.x, y: ball.y }; + if (ours.length === 0) return null; + const byX = [...ours].sort( + (a, b) => b.x - a.x || (a.slot < b.slot ? -1 : a.slot > b.slot ? 1 : 0) + ); + const n = Math.min(BALL_FALLBACK_ADVANCED_COUNT, byX.length); + let sx = 0; + let sy = 0; + for (let i = 0; i < n; i += 1) { + sx += byX[i].x; + sy += byX[i].y; + } + return { x: sx / n, y: sy / n }; +} + +// --------------------------------------------------------------------------- +// Slot naming +// --------------------------------------------------------------------------- + +/** Position codes (app/models/roster.py PositionCode). A rotation's + * what_moves slots are authored as slugs like "cb_l" and "middle_cb", and + * a plain de-slug turns those into "Cb l", which reads like a typo to the + * one audience that knows exactly what CB means. */ +const POSITION_TOKENS = new Set(["gk", "cb", "fb", "wb", "dm", "cm", "am", "st", "ss", "w"]); +const SIDE_TOKENS: Record = { l: "left", r: "right" }; + +/** + * A slot slug as coach-facing text. `sentenceCase` false is for the middle + * of a sentence ("becomes the third CB"), where the leading word must stay + * lower case but a position code must not. + */ +export function slotLabel(slug: string, sentenceCase = true): string { + const words = slug + .split("_") + .map((t) => (POSITION_TOKENS.has(t) ? t.toUpperCase() : (SIDE_TOKENS[t] ?? t))) + .join(" "); + if (!sentenceCase) return words; + return words.charAt(0).toUpperCase() + words.slice(1); +} + +/** Mean vertex, good enough to anchor a chip on the rectangles seeded and + * correct in spirit for any convex polygon a later seed might carry. */ +export function polygonCentroid(polygon: readonly Pt[]): Pt { + if (polygon.length === 0) return { x: 50, y: 50 }; + let sx = 0; + let sy = 0; + for (const p of polygon) { + sx += p.x; + sy += p.y; + } + return { x: sx / polygon.length, y: sy / polygon.length }; +} + +// --------------------------------------------------------------------------- +// Zone read copy (doc 06 sections 2.1 and 5.1 control 3) +// --------------------------------------------------------------------------- + +/** + * The one line a zone card leads with once opposition is on the board. + * + * Every branch names its superiority. The parity branch is the one that + * earns the engine its keep: same bodies, and whether that is an edge + * depends entirely on whether one of ours is unmarked in there, which is + * what `freeManWhy` carries when countZone reported "positional". + */ +export function zoneReadLine(count: ZoneCount, freeManWhy: string | null): string { + if (count.verdict === "superiority") { + return ( + `Numerical superiority: ${count.label} in this zone right now. ` + + `More bodies than they have, so the spare man is real and the ball can be worked through here.` + ); + } + if (count.verdict === "inferiority") { + return ( + `Numerical inferiority: ${count.label} in this zone right now. ` + + `This is their zone. Play around it or move someone into it before you try to play through it.` + ); + } + if (count.superiorityKind === "positional" && freeManWhy) { + return `Parity on bodies at ${count.label}, and still an edge. ${freeManWhy}`; + } + return ( + `Parity: ${count.label} in this zone right now. ` + + `No numerical edge, and nobody of ours is free in here either, so there is no positional edge to play off yet.` + ); +} + +// --------------------------------------------------------------------------- +// The matchup read (doc 06 sections 2.8 and 4) +// --------------------------------------------------------------------------- + +/** Route copy for an INFERRED route. Softer than a seeded route line and + * says so out loud, because doc 06 section 4 requires exactly that when + * `MatchupRead.routeInferred` is true. */ +export function inferredRouteLine(read: MatchupRead): string { + const opener = "Read off the counts on the board rather than from a coached card, so treat it as a starting point."; + if (read.route === "through") { + return `${opener} The middle looks like yours, so the ball probably goes through it.`; + } + if (read.route === "around") { + return `${opener} A flank looks like yours, so the ball probably goes around them.`; + } + return `${opener} Neither the middle nor a flank is yours, so the ball probably has to go over the block.`; +} + +/** The "where are we spare" step. Null when no zone is ours. */ +export function spareLine(read: MatchupRead, zoneName: (zoneKey: string) => string): string | null { + if (!read.spare) return null; + const kind = read.spare.superiorityKind === "positional" ? "Positional" : "Numerical"; + return `${kind} superiority in ${zoneName(read.spare.zoneKey)}: ${read.spare.label}.`; +} + +/** The "where are we short" step. Null when no zone is theirs. */ +export function shortLine(read: MatchupRead, zoneName: (zoneKey: string) => string): string | null { + if (!read.short) return null; + return `Numerical inferiority in ${zoneName(read.short.zoneKey)}: ${read.short.label}.`; +} + +/** Doc 06 section 2.8: with no seeded card, say plainly that this pair has + * no coached read yet. Do not invent one. */ +export const NO_SEEDED_MATCHUP_NOTE = + "This pair has no coached read yet. The counts below are computed live from the two shapes on the board; the route under them is inferred, not written by a coach."; + +// --------------------------------------------------------------------------- +// Positional grid checks (doc 06 section 5.2) +// --------------------------------------------------------------------------- + +const COUNT_WORDS = [ + "Zero", + "One", + "Two", + "Three", + "Four", + "Five", + "Six", + "Seven", + "Eight", + "Nine", + "Ten", + "Eleven", +]; + +function countWord(n: number): string { + return COUNT_WORDS[n] ?? String(n); +} + +/** Copy-ready phrase per lane, so a check reads like a sentence instead of + * a key lookup. Doc 06 section 5.2's own example uses the half-space + * wording verbatim. */ +const LANE_PHRASE: Record = { + left_wing: "on the left wing", + left_half_space: "in the left half-space", + centre: "in the centre lane", + right_half_space: "in the right half-space", + right_wing: "on the right wing", +}; + +const LINE_PHRASE: Record = { + own_build: "in your own build-up line", + first_line: "on the first line", + middle: "across the middle line", + between_the_lines: "between the lines", + last_line: "on the last line", +}; + +function laneLabel(key: string): string { + return JDP_GRID.lanes.find((l) => l.key === key)?.label ?? key; +} + +function lineLabel(key: string): string { + return JDP_GRID.lines.find((l) => l.key === key)?.label ?? key; +} + +/** + * One breach as a coaching CHECK. + * + * Every sentence ends in a question the coach answers, never a verdict the + * app hands down. Doc 06 section 2.2 is explicit that a temporary breach is + * legal football: forming a triangle, creating an overload, dragging a + * marker out of position. So the copy asks whether it is deliberate and + * never says the shape is wrong. + */ +export function breachCheck(breach: GridBreach): string { + const word = countWord(breach.count); + if (breach.kind === "lane_over") { + const phrase = LANE_PHRASE[breach.cell as LaneKey] ?? `in the ${laneLabel(breach.cell).toLowerCase()}`; + return `${word} ${phrase}. Intentional overload, or is someone standing in a teammate's zone?`; + } + if (breach.kind === "wide_lane_shared") { + const phrase = LANE_PHRASE[breach.cell as LaneKey] ?? `in the ${laneLabel(breach.cell).toLowerCase()}`; + return `${word} ${phrase}. Wide lanes hold one each when you can. Is the second man arriving late, or parked there?`; + } + const phrase = LINE_PHRASE[breach.cell as LineKey] ?? `on the ${lineLabel(breach.cell).toLowerCase()}`; + return `${word} ${phrase}. That is one line for them to defend against. Is one of those meant to be higher or deeper?`; +} + +/** What the grid panel says when nothing breaches. Names the superiority, + * same as every other card the engine emits (doc 06 section 2.1). */ +export const NO_BREACH_CHECK = + "No occupancy checks on this shape. Five lanes and five lines, none of them crowded, which is the positional superiority the grid is asking for."; + +/** Interior lane boundaries, in model y. Drawn as the overlay's vertical + * lines in BOTH orientations, since portrait maps left = y. */ +export function laneBoundaries(): number[] { + return JDP_GRID.lanes.slice(0, -1).map((l) => l.max); +} + +/** Interior line boundaries, in model x. Drawn as the overlay's horizontal + * lines: landscape top = x, portrait top = 100 - x. */ +export function lineBoundaries(): number[] { + return JDP_GRID.lines.slice(0, -1).map((l) => l.max); +} diff --git a/frontend/src/pages/patternPreview.ts b/frontend/src/pages/patternPreview.ts index f2520f0..6f6027b 100644 --- a/frontend/src/pages/patternPreview.ts +++ b/frontend/src/pages/patternPreview.ts @@ -50,13 +50,9 @@ export function toDeclarativeSpec(spec: AnimationSpecWire): DeclarativeSpec { }; } -/** Every token a library item's spec names (its slots, plus the ball at - * the initial holder's spot), in landscape model coordinates. Empty when - * the item has no animation spec (should not happen for real content, but - * the field is nullable at the API boundary). */ -function libraryItemTokens(item: LibraryItemOutWire): PreviewToken[] { - const spec = item.animation_spec; - if (!spec) return []; +/** Every token a declarative spec names (its slots, plus the ball at the + * initial holder's spot), in landscape model coordinates. */ +function specTokens(spec: AnimationSpecWire): PreviewToken[] { const tokens: PreviewToken[] = spec.slots.map((s) => ({ id: s.slot, side: s.side === "opponent" ? "away" : "home", @@ -69,23 +65,38 @@ function libraryItemTokens(item: LibraryItemOutWire): PreviewToken[] { return tokens; } -export function libraryItemPreview( - item: LibraryItemOutWire -): { tokens: PreviewToken[]; playback: Playback | null } { - const tokens = libraryItemTokens(item); - const spec = item.animation_spec; - if (tokens.length === 0 || !spec) return { tokens: [], playback: null }; - +/** + * A scene and a Playback for any doc 03 4.1 declarative spec, whoever owns + * it. Extracted from libraryItemPreview (which is now a thin wrapper) so + * the Formations page's Rotations control can play a rotation_systems row's + * animation_spec_json on the same renderer: it is the identical wire shape + * (schemas.py uses one AnimationSpec model for library items, identities + * and rotation systems alike), so the alternative was a duplicate of this + * function that could drift. + */ +export function animationSpecPreview( + spec: AnimationSpecWire +): { tokens: PreviewToken[]; playback: Playback } { + const tokens = specTokens(spec); // Identity binding: the preview's own token ids ARE the spec's slot // names (no shared default-board token set to map onto here). const binding: Record = {}; for (const s of spec.slots) binding[s.slot] = s.slot; - return { tokens, playback: buildDeclarativePlayback(toDeclarativeSpec(spec), binding, "ball") }; } +export function libraryItemPreview( + item: LibraryItemOutWire +): { tokens: PreviewToken[]; playback: Playback | null } { + const spec = item.animation_spec; + // Empty when the item has no animation spec (should not happen for real + // content, but the field is nullable at the API boundary). + if (!spec) return { tokens: [], playback: null }; + return animationSpecPreview(spec); +} + export function libraryItemBoardSnapshot(item: LibraryItemOutWire): BoardSnapshot | null { - const tokens = libraryItemTokens(item); + const tokens = item.animation_spec ? specTokens(item.animation_spec) : []; if (tokens.length === 0) return null; return { tokens: tokens.map((t) => ({ id: t.id, side: t.side, label: t.label, x: t.pos.x, y: t.pos.y })), diff --git a/frontend/src/pages/personnelLab.ts b/frontend/src/pages/personnelLab.ts new file mode 100644 index 0000000..d9b6137 --- /dev/null +++ b/frontend/src/pages/personnelLab.ts @@ -0,0 +1,233 @@ +// Pure helpers for the Formations page's personnel panel (T-107, doc 06 +// sections 2.6, 2.7, 5.3). No React, no DOM, no network: FormationsPage.tsx +// owns the data fetching (roster, archetypes, suggestions, unit balance) +// and this module turns that data into the coach-facing copy and layout +// decisions, unit tested the same way formationsLab.ts is. +// +// TWO RULES THIS MODULE EXISTS TO KEEP HONEST. +// +// 1. A SUGGESTION'S "WHY" IS NEVER RECOMPUTED HERE. The API already cites +// the real reason (backend/app/routers/tactics.py _build_why); this +// module never re-derives, scores, or summarises it. FormationsPage.tsx +// renders ArchetypeSuggestionWire.why verbatim. +// +// 2. FOOTEDNESS NOTES ARE COMPUTED HERE BECAUSE NO ENDPOINT CARRIES THEM. +// Doc 06 section 2.7 is this ticket's own surface: T-108's suggestion +// endpoint uses footedness internally to RANK candidates but never +// returns the coaching note itself, and T-110's balance endpoint knows +// nothing about individual players at all. So the six rules below are +// real (if small) football logic that belongs to this ticket, not a +// duplicate of anything already shipped. + +import type { Flank, PreferredFoot } from "../rosterApi"; + +// --------------------------------------------------------------------------- +// Side, from the coordinate (doc 06 section 2.6 / backend/app/units.py +// flank_of). Landscape model coords, y running 0 to 100 top to bottom +// (CLAUDE.md rule 8): the left half of the pitch is the LOW half of y. +// Derived from the coordinate, never parsed out of a slot id suffix, for +// the same reason the backend does it this way: the coordinate is the +// stored truth and a slot id is a label. +// --------------------------------------------------------------------------- + +export function sideOfY(y: number): Flank { + if (y < 50) return "left"; + if (y > 50) return "right"; + return "center"; +} + +// --------------------------------------------------------------------------- +// Slot family display names (doc 06 section 2.6's ten families). +// --------------------------------------------------------------------------- + +export const FAMILY_LABEL: Record = { + gk: "Goalkeeper", + cb_central: "Centre back", + cb_wide: "Centre back (wide)", + fb: "Fullback", + wb: "Wing back", + six: "Six", + eight: "Eight", + ten: "Ten", + wide_forward: "Wide forward", + nine: "Nine", +}; + +export function familyLabel(slotFamily: string): string { + return FAMILY_LABEL[slotFamily] ?? slotFamily; +} + +// --------------------------------------------------------------------------- +// Personnel groups: a simple, complete PARTITION of the eleven slots for +// the panel's own layout and, on phone, the "one unit at a time" pager +// (doc 06 section 5.4). This is deliberately NOT app/units.py's crosswalk: +// that maps one slot into as many as two units at once (a fullback is in +// the back line AND his flank's wide unit), which is exactly right for +// EVALUATING balance but would mean rendering the same player/archetype +// picker twice for editing. The unit balance section below reads the real +// crosswalk straight off the coach-only balance response instead, so +// nobody duplicates app/units.py's football on this side of the wire. +// --------------------------------------------------------------------------- + +export type PersonnelGroupKey = "gk" | "back_line" | "midfield" | "front_line"; + +const GROUP_FAMILIES: Record = { + gk: ["gk"], + back_line: ["cb_central", "cb_wide", "fb", "wb"], + midfield: ["six", "eight", "ten"], + front_line: ["wide_forward", "nine"], +}; + +const GROUP_LABEL: Record = { + gk: "Goalkeeper", + back_line: "Back line", + midfield: "Midfield", + front_line: "Front line", +}; + +const GROUP_ORDER: PersonnelGroupKey[] = ["gk", "back_line", "midfield", "front_line"]; + +export interface PersonnelGroup { + key: PersonnelGroupKey; + label: string; + slots: T[]; +} + +/** Buckets the eleven slots into the four groups above, in GROUP_ORDER, + * each slot appearing exactly once and in the formation's own slot order + * within its group. A slot whose family matches nothing (should not + * happen against seeded data) is simply omitted rather than guessed at. + * + * Generic over T rather than a fixed shape: the caller's own slot type + * (FormationPositionWire, narrowed to a non-null slot_family) carries + * more fields than grouping needs, and staying generic means this + * function returns THAT type back out rather than a stripped-down copy + * the caller would have to re-merge with the original wire object. */ +export function personnelGroups( + positions: readonly T[] +): PersonnelGroup[] { + return GROUP_ORDER.map((key) => ({ + key, + label: GROUP_LABEL[key], + slots: positions.filter((p) => GROUP_FAMILIES[key].includes(p.slot_family)), + })).filter((g) => g.slots.length > 0); +} + +// --------------------------------------------------------------------------- +// Unit balance display labels (doc 06 sections 2.6, 3.1). The membership +// and the rule firing both come from the coach-only balance response +// (POST /formations/{code}/balance); this is only the copy that turns its +// `unit` code and `flank` into a heading. +// --------------------------------------------------------------------------- + +const UNIT_LABEL: Record = { + midfield_three: "Midfield three", + double_pivot: "Double pivot", + front_three: "Front three", + strike_pair: "Strike pair", + back_line: "Back line", + wide_unit: "Wide unit", + box_midfield: "Box midfield", +}; + +export function unitHeading(unit: string, flank: Flank | null): string { + const label = UNIT_LABEL[unit] ?? unit; + if (!flank || flank === "center") return label; + return `${label}, ${flank}`; +} + +// --------------------------------------------------------------------------- +// The footedness engine (doc 06 section 2.7). All six numbered rules, all +// one-line, all coach-facing, NEVER blocking: nothing here returns +// anything the panel could mistake for a validation error. +// +// Rule 6 first, because rules 1-4 defer to it: `B` (two-footed) suppresses +// every warning below FOR THAT SLOT and says so, because two-footedness is +// a genuine tactical asset and must read as one, not as an absence of +// data. +// --------------------------------------------------------------------------- + +const TWO_FOOTED_NOTE = + "Two-footed. That suppresses the footedness caution for this slot: a genuine tactical asset, not a gap in his game."; + +/** + * Rules 1-3 and 5, per slot. `slotFamily` decides which rule (if any) + * applies; `side` is the slot's own side (sideOfY); `preferredFoot` is the + * assigned player's. Returns null when no player is assigned (nothing to + * say about a foot nobody has picked yet, doc 06 section 5.3's "the panel + * still works ... with no players assigned") or when the slot family/side + * combination carries no rule (doc 06 section 2.7 defines a rule for the + * LEFT centre back only, not the right or the central one in a back + * three, and for wide forwards and wing backs, not for a six/eight/ten/ + * nine/goalkeeper). + * + * Rule 5 (deliveries) is folded into rules 2 and 3's own line rather than + * a separate note: doc 06 section 2.7 asks it to "attach ... next to the + * delivery library links", and the wide forward / wing back line already + * names the in-swing/out-swing consequence in the same breath a coach + * would read it. It does not cite specific F1-F8 codes: seeds/deliveries. + * json carries no in-swing/out-swing attribute on the delivery library + * items themselves (only trajectory: ground/driven/whipped/floated/ + * clipped), and seeds/ is out of this ticket's scope to add one to. + */ +export function slotFootednessNote( + slotFamily: string, + side: Flank, + preferredFoot: PreferredFoot | null +): string | null { + if (preferredFoot === null) return null; + + const appliesToFamily = + ((slotFamily === "cb_central" || slotFamily === "cb_wide") && side === "left") || + slotFamily === "wide_forward" || + slotFamily === "wb"; + if (!appliesToFamily) return null; + + if (preferredFoot === "B") return TWO_FOOTED_NOTE; + + const sameSide = (side === "left" && preferredFoot === "L") || (side === "right" && preferredFoot === "R"); + + if (slotFamily === "cb_central" || slotFamily === "cb_wide") { + // Rule 1: left centre back only (side === "left" is already asserted + // by appliesToFamily above). + return preferredFoot === "R" + ? "Right-footed left centre back. Closed body shape: his first pass points back inside, and a presser who shades him infield takes half the pitch away." + : "Left-footed left centre back. Opens the body to the whole field."; + } + + if (slotFamily === "wide_forward") { + // Rule 2, with rule 5's delivery consequence folded in. + return sameSide + ? "Same-footed to this flank. Touchline profile: holds width and delivers early, and an out-swinging ball from here suits his stronger foot." + : "Opposite-footed to this flank. Inside forward profile: cuts in to shoot and leaves the touchline for an overlapping fullback, and an in-swinging ball from here suits his stronger foot."; + } + + // slotFamily === "wb". Rule 3, with rule 5 folded in. + return sameSide + ? "Same-footed to this flank. Natural early cross, an out-swinging delivery." + : "Opposite-footed to this flank. Cutback and inside combination play, an in-swinging delivery."; +} + +/** + * Rule 4: both fullbacks inverting on the same foot. Unlike rules 1-3 and + * 5, this reads TWO slots at once (fb_l and fb_r), so it cannot be a + * per-slot function: the panel calls it once per formation and attaches + * the result under both fullback rows. + * + * Fires when both fullbacks share the identical preferred foot: the pivot + * then always receives the ball from the same angle, which is what makes + * it predictable to press, regardless of which one of the pair is + * technically "inverting" relative to his own side. Either slot being + * two-footed (rule 6) breaks that predictability on its own, so it + * suppresses this note too, same as it suppresses the single-slot rules. + */ +export function fullbackPairNote( + leftFoot: PreferredFoot | null, + rightFoot: PreferredFoot | null +): string | null { + if (leftFoot === null || rightFoot === null) return null; + if (leftFoot === "B" || rightFoot === "B") return null; + if (leftFoot !== rightFoot) return null; + const footWord = leftFoot === "R" ? "right" : "left"; + return `Both fullbacks are ${footWord}-footed. The pivot receives from the same angle every time and becomes predictable to press.`; +} diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css index b89026d..d4cc4d3 100644 --- a/frontend/src/styles/tokens.css +++ b/frontend/src/styles/tokens.css @@ -1,10 +1,59 @@ /* Design tokens: three themes as CSS custom properties on html[data-theme]. - Source of truth: docs/source/design-handoff/README.md "Design tokens" - table plus the reference values baked into pop-mvp-mockups.html, which is - the interactive source the README table summarizes. + Source of truth: the founder palette directive of 2026-08-07 (T-071), + summarised in docs/source/design-handoff/README.md "Design tokens". + That directive SUPERSEDES the original gold-accent token table in the + design README and the values baked into pop-mvp-mockups.html. The rest + of the design README (layout, interactions, permissions) still wins. Rule: components consume these variables only. No component ships a - hardcoded color. */ + hardcoded color. If you add a token, add it to all three themes. + + TWO LAYERS, AND THEY MUST NOT BE MIXED + -------------------------------------- + 1. CHROME tokens describe the application shell: backgrounds, surfaces, + text, and the brand red that marks every interactive affordance. + 2. BOARD tokens (--pitch-*, --token-face, --team-*, --ball, --lane-*, + --intercept, --mark, --zone, --keystone, --route-badge*) describe a + football pitch. They are defined independently in every theme. + + Nothing under frontend/src/board/, and no board surface anywhere else + (mini thumbnails, keystone rings, the positional grid overlay), may read + a chrome token for a football meaning. Before T-071 the board had no + colours of its own: it borrowed --bg-stripe for turf and --accent/--red + for home/away and for confirmed/blocked lanes, so a red brand accent + would have turned the pitch red and made "this pass is on" and "this + pass is blocked" the same colour. Changing --accent must not be able to + change what the pitch or a lane looks like. e2e/palette.spec.ts proves + it at runtime and scripts/check_palette.py proves it statically (it runs + inside `make check-copy`, so `make verify` covers it); both fail if the + two layers are recoupled. + + CHROME COLOUR RULES + ------------------- + - --accent (brand red) is the only interactive colour and the only red + FILL: buttons, active tabs and tools, nav selection, focus rings, + hover borders, range thumbs. + - --warn (shield gold) carries every non-interactive emphasis and coach + advisory: fit warnings, unit-balance clashes, verdict and status + pills, author stamps, category labels. Nothing gold is clickable. + - --red carries failure only, as text (--text-red), a 1px outline, or a + faint --bg-red tint. It never fills a control. It is held at a + distinctly cooler, crimson value from the scarlet --accent so the two + reds never read as one colour. + + BOARD COLOUR RULES (design README "Visual language on the board") + ---------------------------------------------------------------- + Green turf, gold "the pass is on" (suggested and confirmed lanes, ball, + zones, keystones), red "blocked / opposition / marking ring", in all + three themes. The chrome goes red for the brand; the pitch does not. + + Contrast: every text pair clears WCAG AA 4.5:1 and every graphical pair + that carries meaning clears 3:1. Ratios are asserted in + scripts/check_palette.py, so a future edit that dims a token fails the + suite rather than shipping. + --token-face is the one deliberate exception: it is the disc behind a + token, and a token is identified by its ring and its number, both of + which clear 3:1 against the turf and 4.5:1 against the face. */ :root { --display-font: "Oswald", sans-serif; @@ -12,62 +61,127 @@ --radius: 10px; } -/* Pitch (default) */ +/* Pitch (default): the brand theme. Shield navy chrome, brand red accent, + shield gold advisories, football green pitch. */ html[data-theme="pitch"] { - --bg: #0f3c2c; - --bg-stripe: #3b7a57; - --bg-stripe-alt: #336a4b; - --sidebar-bg: #0b2f22; - --surface: #1b4b39; + /* chrome */ + --bg: #081422; + --sidebar-bg: #0a1a2b; + --surface: #0f2338; --text-primary: #f5f3e9; - --text-secondary: #a9c4b3; - --border: rgba(245, 243, 233, 0.12); - --line: rgba(245, 243, 233, 0.28); - --accent: #e8b923; - --accent-ink: #3a2b00; - --glow: #ffd65a; - --red: #e23d42; - --on-red: #fff; - --bg-red: rgba(226, 61, 66, 0.18); - --text-red: #ff8a8d; + --text-secondary: #a6bcd4; + --border: rgba(245, 243, 233, 0.14); + --line: rgba(245, 243, 233, 0.26); + --accent: #ef5350; + --accent-ink: #2a0605; + --glow: #ff8079; + --warn: #c9a227; + --on-warn: #241a00; + --bg-warn: rgba(201, 162, 39, 0.16); + --text-warn: #e9c651; + --red: #cf3560; + --bg-red: rgba(207, 53, 96, 0.16); + --text-red: #ff8fa8; + /* board */ + --pitch-turf: #2d6434; + --pitch-stripe: #28592e; + --pitch-line: #dceade; + --token-face: #0b1c11; + --team-home: #efc63f; + --team-away: #ff8a8c; + --ball: #ffe27a; + --lane-suggested: #e8b923; + --lane-confirmed: #ffd65a; + --lane-glow: #ffe9a0; + --lane-blocked: #ff8a8c; + --intercept: #ff8a8c; + --mark: #ff8a8c; + --zone: #e8b923; + --keystone: #ffd65a; + --route-badge: #ffd65a; + --route-badge-ink: #33280a; } -/* Dark */ +/* Dark: neutral graphite chrome, same brand red one step brighter, same + pitch language on a night-green turf. */ html[data-theme="dark"] { - --bg: #14161a; - --bg-stripe: #1a1d22; - --bg-stripe-alt: #1a1d22; - --sidebar-bg: #191c21; + /* chrome */ + --bg: #121417; + --sidebar-bg: #17191d; --surface: #1d2025; --text-primary: #ecedee; - --text-secondary: #888e97; - --border: rgba(255, 255, 255, 0.08); - --line: rgba(255, 255, 255, 0.14); - --accent: #4fa8ff; - --accent-ink: #052240; - --glow: #7cc1ff; - --red: #e5484d; - --on-red: #fff; - --bg-red: rgba(229, 72, 77, 0.16); - --text-red: #ff9298; + --text-secondary: #9aa1aa; + --border: rgba(255, 255, 255, 0.09); + --line: rgba(255, 255, 255, 0.15); + --accent: #f4635a; + --accent-ink: #2a0605; + --glow: #ff8b80; + --warn: #d2ab2e; + --on-warn: #201700; + --bg-warn: rgba(210, 171, 46, 0.16); + --text-warn: #e6c24c; + --red: #de3f63; + --bg-red: rgba(222, 63, 99, 0.16); + --text-red: #ff8da4; + /* board */ + --pitch-turf: #1f4a28; + --pitch-stripe: #1b4223; + --pitch-line: #c6dcca; + --token-face: #071009; + --team-home: #e9bf46; + --team-away: #fa8285; + --ball: #f8dd7b; + --lane-suggested: #ddb02a; + --lane-confirmed: #f6cf5e; + --lane-glow: #fae5a2; + --lane-blocked: #fa8285; + --intercept: #fa8285; + --mark: #fa8285; + --zone: #ddb02a; + --keystone: #f6cf5e; + --route-badge: #f6cf5e; + --route-badge-ink: #2e2409; } -/* Board (light) */ +/* Board (light): paper chrome, the logo's own deep brand red, and a chalk + pitch. On a pale turf every mark on the pitch is dark: light lanes and + light tokens cannot hold 3:1 there, so the gold and red go deep instead + of bright. The football language is unchanged, only its value is. */ html[data-theme="board"] { + /* chrome */ --bg: #fafaf6; - --bg-stripe: #f1f2ec; - --bg-stripe-alt: #f1f2ec; - --sidebar-bg: #f1f2ec; - --surface: #fff; + --sidebar-bg: #f0f1eb; + --surface: #ffffff; --text-primary: #1b2420; - --text-secondary: #657064; - --border: rgba(27, 36, 32, 0.12); + --text-secondary: #58635b; + --border: rgba(27, 36, 32, 0.14); --line: rgba(27, 36, 32, 0.22); - --accent: #2d6a4f; - --accent-ink: #eaf3ee; - --glow: #2d6a4f; - --red: #c81e2c; - --on-red: #fff; - --bg-red: #fbe7e7; - --text-red: #b01f26; + --accent: #c81c1c; + --accent-ink: #ffffff; + --glow: #e24a44; + --warn: #8a6a08; + --on-warn: #ffffff; + --bg-warn: #faf2da; + --text-warn: #7a5d06; + --red: #a11331; + --bg-red: #fbe9ee; + --text-red: #8f1130; + /* board */ + --pitch-turf: #d7e6d4; + --pitch-stripe: #cddeca; + --pitch-line: #56785c; + --token-face: #ffffff; + --team-home: #7a5d06; + --team-away: #a5151c; + --ball: #8f6f0a; + --lane-suggested: #8f6f0a; + --lane-confirmed: #6f5405; + --lane-glow: #b8951f; + --lane-blocked: #a5151c; + --intercept: #a5151c; + --mark: #a5151c; + --zone: #7a5d06; + --keystone: #6f5405; + --route-badge: #6f5405; + --route-badge-ink: #ffffff; } diff --git a/frontend/src/tacticsApi.ts b/frontend/src/tacticsApi.ts new file mode 100644 index 0000000..ae6f1cb --- /dev/null +++ b/frontend/src/tacticsApi.ts @@ -0,0 +1,228 @@ +// Wire types and fetch calls for the Tactics Lab routes (T-108, +// backend/app/routers/tactics.py; doc 06 sections 3.2 and 5.1). Mirrors +// backend/app/schemas.py field for field, the same convention +// formationsApi.ts and libraryApi.ts already follow. +// +// Library world only: phases, matchups and rotations carry no team_id, so +// none of these calls takes or sends one (CLAUDE.md rule 4). +// +// T-107 adds the personnel panel's three surfaces below, in doc 06 section +// 5.3 order: the archetype catalog (GET /archetypes, library world, both +// roles), the ranked suggestion list (GET /archetypes/suggest, COACH-ONLY, +// 403s a player token), and the live unit balance evaluation +// (POST /formations/{code}/balance, also COACH-ONLY). FormationsPage.tsx is +// the one caller and is responsible for never firing the two coach-only +// fetchers for a player-role session (CLAUDE.md rule 5); nothing in this +// module gates on role itself, same as every other file in this repo that +// only wraps `fetch`. +// +// The team-formations routes (T-108: GET/POST/PUT /api/team-formations) are +// still deliberately absent here. Doc 06 section 5.3 never asks the +// personnel panel to persist a saved setup, and the balance endpoint's own +// docstring says it evaluates the panel's UNSAVED state: eleven scratch +// picks kept in FormationsPage.tsx's own React state, never written +// anywhere. Wiring team-formations into this panel would be inventing a +// save surface doc 06 does not ask for. + +import { request } from "./api"; +import type { FormationPositionWire } from "./formationsApi"; +import type { AnimationSpecWire } from "./libraryApi"; +import type { Flank, WorkRate } from "./rosterApi"; + +/** formation_phases.phase. `transition` is in the vocabulary but nothing is + * seeded against it yet, so the page's phase segment does not offer it. */ +export type PhaseName = "in_possession" | "out_of_possession" | "rest_defence" | "transition"; + +export interface FormationPhaseWire { + formation_code: string; + variant_code: string; + phase: PhaseName; + name: string; + shape_label: string; + blurb: string; + positions: FormationPositionWire[]; + trigger: string; + rest_shape: string | null; + reference_code: string | null; + uses_rotations: string[]; +} + +export interface FormationMatchupWire { + ours_code: string; + theirs_code: string; + our_edges: string[]; + their_edges: string[]; + route: string; + route_kind: "through" | "around" | "over"; +} + +/** Always 200: `matchup` is null when the pair has no seeded card, which + * doc 06 section 2.8 treats as a normal state to render plainly, not an + * error to hide. */ +export interface FormationMatchupResponseWire { + ours_code: string; + theirs_code: string; + matchup: FormationMatchupWire | null; +} + +export interface RotationMoveWire { + slot: string; + from: { x: number; y: number }; + to: { x: number; y: number }; + becomes?: string | null; +} + +export interface RotationSystemWire { + code: string; + name: string; + family: "first_line" | "pivot" | "wide" | "front_line"; + applies_to_formations: string[]; + produces_shape: string; + trigger: string; + what_moves: RotationMoveWire[]; + coaching_points: string[]; + risk: string; + requires_profile: Record | null; + animation_spec: AnimationSpecWire | null; + exemplar_note: string | null; +} + +export function listFormationPhases(code: string): Promise { + return request(`/formations/${encodeURIComponent(code)}/phases`); +} + +export function getFormationMatchup( + ours: string, + theirs: string +): Promise { + const query = `ours=${encodeURIComponent(ours)}&theirs=${encodeURIComponent(theirs)}`; + return request(`/formations/matchup?${query}`); +} + +export function listRotations(formationCode: string): Promise { + return request( + `/rotations?formation_code=${encodeURIComponent(formationCode)}` + ); +} + +// --------------------------------------------------------------------------- +// Position archetypes (doc 06 sections 2.6, 3.1, 5.3; T-102/T-108). Library +// world, read-only, both roles: no different from listFormations or +// listRotations above. +// --------------------------------------------------------------------------- + +export interface PositionArchetypeWire { + code: string; + slot_family: string; + name: string; + definition: string; + key_attribute_keys: string[]; + foot_hint: "same_side" | "opposite_side" | "either" | null; + awr_default: WorkRate; + dwr_default: WorkRate; + duties: string[]; + enables_pattern_codes: string[]; + enables_rotation_codes: string[]; + needs_around_it: string; + exemplar_note: string | null; +} + +export function listArchetypes(slotFamily?: string): Promise { + const qs = slotFamily ? `?slot_family=${encodeURIComponent(slotFamily)}` : ""; + return request(`/archetypes${qs}`); +} + +// --------------------------------------------------------------------------- +// Archetype suggestion ranking (doc 06 section 5.3), COACH-ONLY: 403s a +// player token (backend/app/routers/tactics.py suggest_archetypes, +// require_role_on_team("coach")). `why` is the server's own cited reason, +// built from the player's real attribute values, footedness and work +// rates ("passing range 5 and positional discipline 4 fit the metronome"), +// never a score; render it verbatim (doc 06 section 5.3, this ticket's own +// non-negotiable). +// --------------------------------------------------------------------------- + +export interface ArchetypeSuggestionWire { + archetype_code: string; + archetype_name: string; + slot_family: string; + why: string; +} + +/** `player_id` echoes back null when the caller asked for no player (the + * empty-roster / unassigned-slot state, doc 06 section 5.3), not an + * error: the response still carries a usable top three. */ +export interface ArchetypeSuggestResponseWire { + slot_family: string; + player_id: number | null; + suggestions: ArchetypeSuggestionWire[]; +} + +export function suggestArchetypes(params: { + slotFamily: string; + playerId?: number | null; + side?: Flank | null; +}): Promise { + const query = new URLSearchParams({ slot_family: params.slotFamily }); + if (params.playerId !== undefined && params.playerId !== null) { + query.set("player_id", String(params.playerId)); + } + if (params.side) query.set("side", params.side); + return request(`/archetypes/suggest?${query.toString()}`); +} + +// --------------------------------------------------------------------------- +// Unit balance evaluation (doc 06 sections 2.6, 3.1, 5.3; T-110), COACH-ONLY: +// 403s a player token (backend/app/routers/tactics.py evaluate_balance, +// require_role_on_team("coach")). POST because it evaluates the personnel +// panel's own UNSAVED eleven picks, not a saved row (module comment above). +// --------------------------------------------------------------------------- + +export interface UnitBalanceSlotWire { + slot: string; + archetype_code: string | null; +} + +/** One fired unit_balance_rules row. `message` is the seeded warning_copy + * verbatim (backend/app/units.py); this ticket's non-negotiable is to + * render it as-is, never compose or soften it further, because the seeded + * copy already reads as a check rather than an error. */ +export interface UnitBalanceNoteWire { + code: string; + unit: string; + flank: Flank | null; + severity: "note" | "warning"; + message: string; + slots: string[]; +} + +export interface UnitBalanceUnitWire { + unit: string; + /** Set only for wide_unit, which occurs once per touchline. */ + flank: Flank | null; + slots: string[]; + assigned_slots: string[]; + is_complete: boolean; + notes: UnitBalanceNoteWire[]; +} + +/** `units_not_evaluated` is part of the contract, not debug output: a unit + * the current formation does not contain (a 4-3-3 has no double pivot) is + * simply absent from `units` and named here instead, so the panel can say + * nothing about it rather than shout a warning about a unit that is not + * on the pitch. */ +export interface UnitBalanceResponseWire { + formation_code: string; + units: UnitBalanceUnitWire[]; + units_not_evaluated: string[]; +} + +export function evaluateUnitBalance( + formationCode: string, + slots: UnitBalanceSlotWire[] +): Promise { + return request( + `/formations/${encodeURIComponent(formationCode)}/balance`, + { method: "POST", body: JSON.stringify({ slots }) } + ); +} diff --git a/scripts/build_logo_assets.py b/scripts/build_logo_assets.py new file mode 100644 index 0000000..a0f8dcc --- /dev/null +++ b/scripts/build_logo_assets.py @@ -0,0 +1,543 @@ +#!/usr/bin/env python3 +"""Derive the shipped logo assets in frontend/public/ from the source PNG. + +Build-time only. Requires Pillow, which is NOT part of the app's runtime +requirements (it never runs in the FastAPI process or in CI's make verify): +install it into a throwaway venv and run this script by hand whenever the +source art changes. + + python3 -m venv .venv && .venv/bin/pip install pillow + .venv/bin/python3 scripts/build_logo_assets.py + +Input: assets/brand/patternsofplaylogo.png (1024x1024, solid red background, +navy shield, gold five-star arc, red maple leaf, green grass base, white +"PATTERNS OF PLAY" wordmark below the shield). The background is a near +uniform red (samples run roughly #C61C1A..#CA1E1E with a soft gradient +toward the corners) that must NOT be blindly colour-keyed across the whole +canvas: two other things in the source are red too and must survive. + + - The maple leaf fill and the shield's inner ring are a deliberate, large, + fully-enclosed red shape sitting inside the navy shield outline. + - Less obviously: the white wordmark letters (both the arc on the shield + and the freestanding line below it) are drawn ON TOP of the red field, + so the background red is still visible, unchanged, inside every closed + letter counter (the hole in a P, R, A, O). Keying by border-reachability + alone leaves those red islands opaque, since the white stroke encloses + and disconnects them from the outer background, which reads as a + scatter of red blobs where letters should be. + +Approach: connected-component colour keying, not a plain border flood fill. +1. Classify every pixel as "background-like" purely by colour distance to + the sampled background red (COLOR_THRESH), no connectivity yet. +2. Group those pixels into 4-connected components (pure Python BFS: the + source is small enough, ~1e6 px, that this runs in a few seconds). +3. A component becomes transparent if it touches the image border (that is + the actual background field, however the gradient shades it) OR if it is + small (SMALL_COMPONENT_MAX px). Small-and-enclosed is the letter-counter + case above: nothing else in the art is both red-like and that small. The + leaf fill and the shield ring are red-like too but are thousands of + pixels and never touch the border, so this rule leaves them opaque. +4. Erode the resulting mask by one 3x3 MIN filter pass and Gaussian-blur it + a fraction of a pixel, so the cut edge is antialiased instead of a hard + 1-bit stairstep. Step 1 already reclassified most of the soft red-to-art + antialiasing blend as background (a generous COLOR_THRESH), so this last + erosion pass only needs to mop up a residual pixel or two, not carry the + whole edge the way a naive hard key would. + +Verify by opening the PNGs in build/logo_review/ (composited over both a +near-black and a near-white square) after a run. +""" + +from __future__ import annotations + +import pathlib +from collections import deque + +from PIL import Image, ImageFilter + +REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent +SOURCE = REPO_ROOT / "assets" / "brand" / "patternsofplaylogo.png" +PUBLIC = REPO_ROOT / "frontend" / "public" +REVIEW_DIR = REPO_ROOT / "build" / "logo_review" + +# Generous: the background has a soft gradient toward the corners and the +# art-to-background edge is itself a soft multi-pixel blend (this is +# generated art, not a crisp vector cutout), so a tight threshold leaves a +# visible red-orange rim once composited on a non-red surface. Safe to be +# generous here because step 3 above (the size/border-touching rule), not +# this threshold, is what keeps the leaf and ring opaque. +COLOR_THRESH = 100 +# Trapped letter counters run well under a hundred pixels at this art's +# text size; the leaf fill and shield ring are tens of thousands of pixels. +# Wide margin between the two, so this does not need to be precise. +SMALL_COMPONENT_MAX = 1500 +ERODE_PASSES = 1 +FEATHER_RADIUS = 0.6 # soft edge, small enough to stay crisp at 28-40px + +# Icon colour used to flatten the apple-touch-icon (transparency renders as +# solid black on iOS home screens, so that variant needs an opaque backing). +# Matches the shield navy sampled from the source art (also T-071's +# --shield-navy brand constant); this is a baked pixel in a raster icon +# asset, not a themed UI component, so it is exempt from the "tokens only" +# rule (there is no CSS variable a file can consume). +ICON_BACKDROP = (22, 48, 79) + + +def load_source() -> Image.Image: + return Image.open(SOURCE).convert("RGB") + + +def sample_background_ref(rgb: Image.Image) -> tuple[int, int, int]: + """Median colour of the four image edges: robust to the gradient and to + the odd stray art pixel that happens to touch the border.""" + px = rgb.load() + w, h = rgb.size + samples = [] + for x in range(0, w, 5): + samples.append(px[x, 0]) + samples.append(px[x, h - 1]) + for y in range(0, h, 5): + samples.append(px[0, y]) + samples.append(px[w - 1, y]) + rs = sorted(s[0] for s in samples) + gs = sorted(s[1] for s in samples) + bs = sorted(s[2] for s in samples) + mid = len(samples) // 2 + return (rs[mid], gs[mid], bs[mid]) + + +def remove_background(rgb: Image.Image) -> Image.Image: + """Colour-key the red background to transparent via connected + components (see module docstring): catches both the outer field and + the red trapped inside closed wordmark letterforms, without touching + the leaf fill or the shield's inner ring. Returns RGBA.""" + w, h = rgb.size + px = rgb.load() + bg_ref = sample_background_ref(rgb) + thresh2 = COLOR_THRESH * COLOR_THRESH + + candidate = bytearray(w * h) + for y in range(h): + row = y * w + for x in range(w): + r, g, b = px[x, y] + dr, dg, db = r - bg_ref[0], g - bg_ref[1], b - bg_ref[2] + if dr * dr + dg * dg + db * db <= thresh2: + candidate[row + x] = 1 + + visited = bytearray(w * h) + bg_mask = bytearray(w * h) + for start in range(w * h): + if not candidate[start] or visited[start]: + continue + comp = [start] + visited[start] = 1 + sy, sx = divmod(start, w) + touches_border = sx == 0 or sy == 0 or sx == w - 1 or sy == h - 1 + q = deque([start]) + while q: + cur = q.popleft() + cy, cx = divmod(cur, w) + for nx, ny in ((cx - 1, cy), (cx + 1, cy), (cx, cy - 1), (cx, cy + 1)): + if 0 <= nx < w and 0 <= ny < h: + nidx = ny * w + nx + if candidate[nidx] and not visited[nidx]: + visited[nidx] = 1 + comp.append(nidx) + q.append(nidx) + if nx == 0 or ny == 0 or nx == w - 1 or ny == h - 1: + touches_border = True + if touches_border or len(comp) <= SMALL_COMPONENT_MAX: + for idx in comp: + bg_mask[idx] = 1 + + alpha = Image.frombytes("L", (w, h), bytes(255 * (1 - b) for b in bg_mask)) + + for _ in range(ERODE_PASSES): + alpha = alpha.filter(ImageFilter.MinFilter(3)) + alpha = alpha.filter(ImageFilter.GaussianBlur(FEATHER_RADIUS)) + + out = Image.new("RGBA", (w, h)) + out.paste(rgb, (0, 0)) + out.putalpha(alpha) + return out + + +def bbox_in_rows(rgba: Image.Image, row_start: int, row_end: int, alpha_thresh: int = 16): + """Tight (left, top, right, bottom) box of opaque pixels within + [row_start, row_end), scanning only the given row window so a crop can + exclude, e.g., the grass base or wordmark that sit below it.""" + px = rgba.load() + w = rgba.width + left, top, right, bottom = w, row_end, 0, row_start + for y in range(row_start, row_end): + for x in range(w): + if px[x, y][3] > alpha_thresh: + left = min(left, x) + right = max(right, x) + top = min(top, y) + bottom = max(bottom, y) + return (left, top, right + 1, bottom + 1) + + +def find_grass_start_row(rgba: Image.Image, row_start: int, row_end: int) -> int: + """First row (top to bottom) where grass-green pixels appear: green + channel clearly, substantially dominant over red and blue (a wide + margin, not just green being the largest of three close values). Shield + navy/gold, the red ring/leaf, and critically the near-white antialiased + edges of the wordmark banner arced across the shield (those are near + neutral grays where green can win by a couple of units on rounding + noise alone) all fail this; only real grass-green passes. Used to crop + the nav/favicon mark above the grass base.""" + px = rgba.load() + w = rgba.width + for y in range(row_start, row_end): + hits = 0 + for x in range(w): + r, g, b, a = px[x, y] + if a > 16 and g - r > 20 and g - b > 15 and r < 120: + hits += 1 + if hits > 5: + return y + return row_end + + +def find_grass_end_row(rgba: Image.Image, row_start: int, row_end: int) -> int: + """Last row (top to bottom) with grass-green pixels, scanning downward + from find_grass_start_row's result toward the bottom of the source + art. Everything below this row is the freestanding flat "PATTERNS OF + PLAY" wordmark banner, not the grass base: a caller that wants "shield + + stars + grass, no wordmark" crops here instead of at full_box's + bottom (see the T-070 follow-up: that flat wordmark is white text with + no backing once the red field is keyed out, illegible on light theme + grounds, so the lockup drops it and lets the shield's own arched + lettering carry the name instead).""" + px = rgba.load() + w = rgba.width + last = row_start + for y in range(row_start, row_end): + hits = 0 + for x in range(w): + r, g, b, a = px[x, y] + if a > 16 and g - r > 20 and g - b > 15 and r < 120: + hits += 1 + if hits > 5: + last = y + break + return last + + +def row_extents(rgba: Image.Image, alpha_thresh: int = 16): + """Per-row (min_x, max_x) of opaque pixels, or None for an empty row. + One O(w*h) pass so find_shield_top_row can answer "what would the + windowed bbox width be starting at row y" in O(1) per row instead of + rescanning every pixel for every candidate row.""" + px = rgba.load() + w, h = rgba.size + extents: list[tuple[int, int] | None] = [None] * h + for y in range(h): + left = right = None + for x in range(w): + if px[x, y][3] > alpha_thresh: + if left is None: + left = x + right = x + if left is not None: + extents[y] = (left, right) + return extents + + +def find_shield_top_row(extents, top: int, grass_row: int) -> int: + """First row (top to bottom) at which the shield's own silhouette has + reached its stable full width: the row above which the windowed bbox + [row, grass_row) would still be widened by the star arc and the two + small decorative maple-leaf sprigs that flank it. Those cannot be + separated from the shield by colour or a simple connected-component + pass (in the source art the centre star's point and the sprigs touch + the shield's shoulders, so all of it is one connected opaque blob, + same shape as the trapped-letter problem remove_background's + docstring describes for the wordmark). Reference width is measured + just above the grass line, where only the shield itself can possibly + still be present; walking down from the top of the art until the + windowed width settles to that reference finds the row where the + stars and sprigs drop out of the window. Used only to build a + stars-free favicon crop (T-070 follow-up: the star arc dissolves into + noise at 16px). The nav rail mark and apple-touch-icon keep the star + arc and are unaffected.""" + ref_top = max(top, grass_row - 80) + suffix_min: list[int | None] = [None] * grass_row + suffix_max: list[int | None] = [None] * grass_row + cur_min = cur_max = None + for y in range(grass_row - 1, top - 1, -1): + e = extents[y] + if e is not None: + l, r = e + cur_min = l if cur_min is None else min(cur_min, l) + cur_max = r if cur_max is None else max(cur_max, r) + suffix_min[y] = cur_min + suffix_max[y] = cur_max + ref_min, ref_max = suffix_min[ref_top], suffix_max[ref_top] + if ref_min is None: + return top + ref_width = ref_max - ref_min + for y in range(top, grass_row): + if suffix_min[y] is None: + continue + if suffix_max[y] - suffix_min[y] <= ref_width + 2: + return y + return top + + +def largest_red_component(rgba: Image.Image): + """4-connected component analysis (see remove_background) restricted + to red-dominant pixels, returns (mask, mean_colour) for the LARGEST + such component. In the source art this is always the solid maple-leaf + fill: the only other red-dominant shape at this scale is the thin + ring circumscribing it, which despite its long perimeter has far + fewer pixels (a stroke, not a fill) and is never the largest + component. Classified by "red dominant over green and blue" rather + than distance to one sampled reference pixel, so this keeps working + if the art's exact red hue ever shifts.""" + w, h = rgba.size + px = rgba.load() + candidate = bytearray(w * h) + for y in range(h): + row = y * w + for x in range(w): + r, g, b, a = px[x, y] + if a > 16 and r - g > 40 and r - b > 40 and r > 100: + candidate[row + x] = 1 + + visited = bytearray(w * h) + best: list[int] = [] + for start in range(w * h): + if not candidate[start] or visited[start]: + continue + visited[start] = 1 + comp = [start] + q = deque([start]) + while q: + cur = q.popleft() + cy, cx = divmod(cur, w) + for nx, ny in ((cx - 1, cy), (cx + 1, cy), (cx, cy - 1), (cx, cy + 1)): + if 0 <= nx < w and 0 <= ny < h: + nidx = ny * w + nx + if candidate[nidx] and not visited[nidx]: + visited[nidx] = 1 + comp.append(nidx) + q.append(nidx) + if len(comp) > len(best): + best = comp + + mask = bytearray(w * h) + r_total = g_total = b_total = 0 + for idx in best: + mask[idx] = 1 + y, x = divmod(idx, w) + pr, pg, pb, _ = px[x, y] + r_total += pr + g_total += pg + b_total += pb + n = max(len(best), 1) + mean_color = (r_total // n, g_total // n, b_total // n) + return Image.frombytes("L", (w, h), bytes(255 * v for v in mask)), mean_color + + +def build_favicon_mini(crop: Image.Image) -> Image.Image: + """Flatten a shield-only crop to two flat colours for the 16px favicon + (T-070 follow-up): shield navy everywhere the source has any opacity, + with only the leaf's own connected component (largest_red_component) + painted back on top in its own colour. Drops the gold border, the + thin red ring and the arched "PATTERNS OF PLAY" lettering: none of + that fine detail survives resampling to 16px, it just reads as + grey-brown noise (see the shipped-before comparison in the follow-up + report), so the mini favicon ships only the two shapes that actually + do survive at that size.""" + w, h = crop.size + px = crop.load() + alpha_mask = bytearray(w * h) + for y in range(h): + for x in range(w): + if px[x, y][3] > 16: + alpha_mask[y * w + x] = 1 + leaf_mask, leaf_color = largest_red_component(crop) + + out = Image.new("RGBA", (w, h), (0, 0, 0, 0)) + alpha_im = Image.frombytes("L", (w, h), bytes(255 * v for v in alpha_mask)) + navy_layer = Image.new("RGBA", (w, h), ICON_BACKDROP + (255,)) + out.paste(navy_layer, (0, 0), alpha_im) + leaf_layer = Image.new("RGBA", (w, h), leaf_color + (255,)) + out.paste(leaf_layer, (0, 0), leaf_mask) + return out + + +def pad(box, amount, size): + left, top, right, bottom = box + w, h = size + return ( + max(0, left - amount), + max(0, top - amount), + min(w, right + amount), + min(h, bottom + amount), + ) + + +def save_png(im: Image.Image, path: pathlib.Path, quantize: int | None = 64) -> int: + """RGBA source art has almost no compression-friendly repetition (the + feathered alpha edge alone produces thousands of near-unique RGBA + tuples), so a straight optimize=True save of the full lockup came out + at ~390KB. Quantizing to a small palette first (FASTOCTREE preserves + per-pixel alpha, unlike the default P-mode single-transparent-index + palette) is the only size lever available without ImageMagick/cwebp, + and gets every shipped file comfortably under the 40KB target with no + visible banding at these sizes.""" + path.parent.mkdir(parents=True, exist_ok=True) + out = im + if quantize and im.mode == "RGBA": + out = im.quantize(colors=quantize, method=Image.FASTOCTREE, dither=Image.NONE) + out.save(path, format="PNG", optimize=True, compress_level=9) + return path.stat().st_size + + +def composite_review(im: Image.Image, name: str) -> None: + im = im.convert("RGBA") + REVIEW_DIR.mkdir(parents=True, exist_ok=True) + for label, bg in (("dark", (18, 18, 20)), ("light", (245, 245, 240))): + canvas = Image.new("RGB", im.size, bg) + canvas.paste(im, (0, 0), im) + canvas.save(REVIEW_DIR / f"{name}-on-{label}.png") + + +def main() -> None: + rgb = load_source() + rgba = remove_background(rgb) + w, h = rgba.size + + # Full art bounding box (stars, shield, grass, wordmark): matches the + # founder-measured box (x 162..864, y 154..954 on the 1024x1024 source) + # to within the flood-fill's own edge trim. + full_box = pad(bbox_in_rows(rgba, 0, h), 6, (w, h)) + + # Shield + star-arc only, excluding the grass base and the wordmark + # below it (nav rail mark and apple-touch-icon source): crop above the + # row where grass green first appears. + grass_row = find_grass_start_row(rgba, full_box[1], full_box[3]) + shield_box = pad(bbox_in_rows(rgba, full_box[1], grass_row), 6, (w, h)) + + # Shield + stars + grass, excluding the flat wordmark banner below the + # grass (T-070 follow-up, defect 1): that banner is white text with no + # backing once the red field is keyed out, near-invisible on the + # board theme's light background. The shield's own arched "PATTERNS OF + # PLAY" lettering, white on navy, carries the name instead and reads + # on any ground. + grass_end = find_grass_end_row(rgba, grass_row, full_box[3]) + lockup_box = pad(bbox_in_rows(rgba, full_box[1], grass_end), 6, (w, h)) + + # Shield only, no star arc and no decorative leaf sprigs (T-070 + # follow-up, defect 2): the star arc dissolves into noise at 16px, so + # the favicon crops tighter than the nav rail mark. See + # find_shield_top_row's docstring for why this needs its own row scan + # instead of reusing shield_box's top. + favicon_top = find_shield_top_row(row_extents(rgba), full_box[1], grass_row) + favicon_box = pad(bbox_in_rows(rgba, favicon_top, grass_row), 4, (w, h)) + + full_lockup = rgba.crop(lockup_box) + shield_mark = rgba.crop(shield_box) + favicon_detail = rgba.crop(favicon_box) + favicon_mini = build_favicon_mini(favicon_detail) + + # 1. Full lockup, transparent background: shield + stars + grass for + # the sign-in screen (no flat wordmark banner, see above). Downscaled + # from the native crop to a size that still renders crisply at typical + # sign-in display widths (roughly 240-320 CSS px) at 2x density. + lockup_w = 640 + lockup_h = round(full_lockup.height * (lockup_w / full_lockup.width)) + full_lockup_out = full_lockup.resize((lockup_w, lockup_h), Image.LANCZOS) + size_lockup = save_png(full_lockup_out, PUBLIC / "logo-lockup.png") + + # 2. Shield mark, transparent background, nav rail (28-40px render). + # Shipped at 2x density for a ~28-32px logical size (a 3x device needs + # 84-96px, which this already covers), so a single asset is enough: + # no srcSet, no second candidate nobody's viewport can ever select + # (T-070 follow-up, defect 3: that used to be shield-mark-144.png). + mark_out_bytes = save_png( + shield_mark.resize( + (round(shield_mark.width * (96 / shield_mark.height)), 96), Image.LANCZOS + ), + PUBLIC / "shield-mark-96.png", + ) + + # 3. Favicon set (T-070 follow-up, defect 2). Two sizes, not three: + # nothing in this app requests a 48px favicon (no manifest.json, no + # browserconfig.xml tile), so it was dead weight the same way + # shield-mark-144 was. 16px is designed for, not downsampled to: the + # flattened navy-shield-plus-leaf mark (favicon_mini) is what actually + # survives that small; 32px keeps the full engraved detail (gold + # border, ring, arched lettering), which is legible at that size. + favicon_sources = {16: favicon_mini, 32: favicon_detail} + favicon_bytes = {} + for size, source in favicon_sources.items(): + target_w = round(source.width * (size / source.height)) + resized = source.resize((target_w, size), Image.LANCZOS) + # Favicons are square; center the (narrower than tall) mark on a + # transparent square canvas so it isn't squashed. + canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + canvas.paste(resized, ((size - resized.width) // 2, (size - resized.height) // 2), resized) + favicon_bytes[size] = save_png(canvas, PUBLIC / f"favicon-{size}.png") + + # favicon.ico (T-070 follow-up, defect 2): Chrome requests + # /favicon.ico at the document root by habit even when is present, and that request 404ed with nothing at that + # path. Built from the same flattened mini mark as favicon-16.png (an + # ICO's job here is just to resolve that implicit request cleanly; a + # modern browser prefers the -declared PNGs for the actual tab + # icon whenever they're present). Square canvas first so Pillow's + # multi-size ICO writer downsamples without distorting the aspect + # ratio. + ico_canvas_size = 64 + ico_target_w = round(favicon_mini.width * (ico_canvas_size / favicon_mini.height)) + ico_resized = favicon_mini.resize((ico_target_w, ico_canvas_size), Image.LANCZOS) + ico_master = Image.new("RGBA", (ico_canvas_size, ico_canvas_size), (0, 0, 0, 0)) + ico_master.paste( + ico_resized, ((ico_canvas_size - ico_resized.width) // 2, 0), ico_resized + ) + ico_path = PUBLIC / "favicon.ico" + ico_master.save(ico_path, format="ICO", sizes=[(16, 16), (32, 32)]) + size_ico = ico_path.stat().st_size + + # apple-touch-icon: opaque backing (iOS renders transparency as black), + # shield mark centered with ~12% padding on the shield-navy backdrop. + touch_size = 180 + pad_frac = 0.12 + inner_h = round(touch_size * (1 - 2 * pad_frac)) + inner_w = round(shield_mark.width * (inner_h / shield_mark.height)) + if inner_w > touch_size * (1 - 2 * pad_frac): + inner_w = round(touch_size * (1 - 2 * pad_frac)) + inner_h = round(shield_mark.height * (inner_w / shield_mark.width)) + resized_touch = shield_mark.resize((inner_w, inner_h), Image.LANCZOS) + touch_canvas = Image.new("RGBA", (touch_size, touch_size), ICON_BACKDROP + (255,)) + touch_canvas.paste( + resized_touch, + ((touch_size - inner_w) // 2, (touch_size - inner_h) // 2), + resized_touch, + ) + size_touch = save_png(touch_canvas.convert("RGB"), PUBLIC / "apple-touch-icon.png") + + # Review composites (not shipped, gitignored build/ dir): eyeball the + # background key over both a near-black and a near-white ground. + composite_review(full_lockup_out, "logo-lockup") + composite_review(Image.open(PUBLIC / "shield-mark-96.png"), "shield-mark") + composite_review(Image.open(PUBLIC / "favicon-32.png"), "favicon-32") + composite_review(Image.open(PUBLIC / "favicon-16.png"), "favicon-16") + + print(f"logo-lockup.png: {size_lockup} bytes ({lockup_w}x{lockup_h})") + print(f"shield-mark-96.png: {mark_out_bytes} bytes") + for size in sorted(favicon_sources): + print(f"favicon-{size}.png: {favicon_bytes[size]} bytes") + print(f"favicon.ico: {size_ico} bytes") + print(f"apple-touch-icon.png: {size_touch} bytes ({touch_size}x{touch_size})") + print(f"Review composites written to {REVIEW_DIR}") + + +if __name__ == "__main__": + main() diff --git a/scripts/check_palette.py b/scripts/check_palette.py new file mode 100644 index 0000000..5835cdd --- /dev/null +++ b/scripts/check_palette.py @@ -0,0 +1,377 @@ +#!/usr/bin/env python3 +"""Palette check (T-071): the design system's two token layers stay apart, +and every colour pair that carries meaning clears WCAG AA. + +Runs in `make verify` via the check-copy target, next to the em dash scan +and the seed validator, because it is the same kind of guard: a rule the +whole codebase depends on that no single component can enforce alone. + +Three things are asserted, and every one of them is a bug this ticket had +to fix, so every one is worth failing the build over. + +1. Both layers are complete in all three themes. + --pitch-turf was REFERENCED by PitchMarkings.tsx and defined by nothing, + so the board had no green of its own: it only looked like grass because + the wrapper borrowed the chrome's --bg-stripe. A token referenced but + never defined is exactly the failure mode this catches. + +2. The board layer never resolves to a chrome value, and football surfaces + never read a chrome colour token. If a future edit points --team-home + back at --accent, or paints a lane with --red, this fails. The invariant + is: changing --accent must not be able to change what the pitch or a + lane looks like. (e2e/palette.spec.ts proves the same thing at runtime + in a real browser, by overriding --accent and re-reading the pitch.) + +3. WCAG AA on the pairs that carry meaning: 4.5:1 for text, 3:1 for + graphics and borders. Computed, never eyeballed. +""" + +import pathlib +import re +import sys + +root = pathlib.Path(__file__).resolve().parent.parent +TOKENS_CSS = root / "frontend/src/styles/tokens.css" +BOARD_DIR = root / "frontend/src/board" + +THEMES = ["pitch", "dark", "board"] + +# Chrome: the application shell. --accent is the brand red and the only +# interactive colour, --warn is shield gold for advisories and read-only +# emphasis, --red is failure status and never fills a control. +CHROME_TOKENS = [ + "--bg", + "--sidebar-bg", + "--surface", + "--text-primary", + "--text-secondary", + "--border", + "--line", + "--accent", + "--accent-ink", + "--glow", + "--warn", + "--on-warn", + "--bg-warn", + "--text-warn", + "--red", + "--bg-red", + "--text-red", +] + +# Board: a football pitch. Defined independently in every theme. +BOARD_TOKENS = [ + "--pitch-turf", + "--pitch-stripe", + "--pitch-line", + "--token-face", + "--team-home", + "--team-away", + "--ball", + "--lane-suggested", + "--lane-confirmed", + "--lane-glow", + "--lane-blocked", + "--intercept", + "--mark", + "--zone", + "--keystone", + "--route-badge", + "--route-badge-ink", +] + +# Chrome colour tokens a board token must never equal. The neutrals are left +# out on purpose: a turf and a background may share a shade of nothing in +# particular, but a football colour must never BE the brand accent, its +# glow, the status red, or the advisory gold. +CHROME_COLOURS = ["--accent", "--glow", "--red", "--warn"] + +# Selectors that draw the pitch itself. Chrome INSIDE the board panel (the +# toolbar, the view menu, the save bar) legitimately reads --accent; these +# do not. Each maps to the file it must live in. +FOOTBALL_SELECTORS = { + "Board.css": [ + ".board-wrap", + ".token-ball .token-face", + ".lane-suggested", + ".lane-confirmed", + ".lane-blocked", + ".lane-dot", + ".mark-ring", + ".mark-tight", + ".zone-rect", + ".zone-divider", + ".zone-label", + ".ball-trail", + ".route-badge", + ".route-badge-num", + ], + "PatternPreviewBoard.css": [ + ".rondo-zone-poly", + ".rondo-zone-label", + ], +} + +# Text needs 4.5:1. --accent is a button LABEL colour as well as a button +# fill (global.css .ctl-ghost:hover, auth.css links, Board.css .restart-btn), +# so it is held to the text bar, not the graphics one. +TEXT_PAIRS = [ + ("--text-primary", "--bg"), + ("--text-primary", "--surface"), + ("--text-primary", "--sidebar-bg"), + ("--text-secondary", "--bg"), + ("--text-secondary", "--surface"), + ("--accent", "--bg"), + ("--accent", "--surface"), + ("--accent-ink", "--accent"), + ("--text-warn", "--bg"), + ("--text-warn", "--surface"), + ("--on-warn", "--warn"), + ("--text-red", "--bg"), + ("--text-red", "--surface"), + # A player token's number sits on its face, and a number is text. + ("--team-home", "--token-face"), + ("--team-away", "--token-face"), + ("--route-badge-ink", "--route-badge"), +] + +# Graphics and borders need 3:1. Every mark on the pitch whose colour +# carries meaning, against the turf, plus the two chrome outlines that are +# the only non-text use of a status colour. +# +# --token-face against the turf is deliberately absent: it is the disc +# BEHIND a token, and a token is identified by its ring and its number, +# which are held to 3:1 against the turf and 4.5:1 against the face above. +GRAPHIC_PAIRS = [ + ("--team-home", "--pitch-turf"), + ("--team-away", "--pitch-turf"), + ("--ball", "--pitch-turf"), + ("--pitch-line", "--pitch-turf"), + ("--lane-suggested", "--pitch-turf"), + ("--lane-confirmed", "--pitch-turf"), + ("--lane-blocked", "--pitch-turf"), + ("--intercept", "--pitch-turf"), + ("--mark", "--pitch-turf"), + ("--zone", "--pitch-turf"), + ("--keystone", "--pitch-turf"), + ("--warn", "--surface"), + ("--red", "--surface"), +] + +# The brand accent (a warm scarlet, the only red FILL) and the status red (a +# cooler crimson, text and outlines only) are both reds now, so they are +# held apart by value as well as by the form rule written into tokens.css. +MIN_ACCENT_VS_RED = 1.25 +MIN_ACCENT_VS_TEXT_RED = 1.35 + +failures: list[str] = [] + + +def parse_themes(css: str) -> dict[str, dict[str, str]]: + out: dict[str, dict[str, str]] = {} + for theme in THEMES: + block = re.search( + r'html\[data-theme="%s"\]\s*\{(.*?)\n\}' % theme, css, re.DOTALL + ) + if not block: + failures.append(f'tokens.css: no html[data-theme="{theme}"] block') + out[theme] = {} + continue + found: dict[str, str] = {} + for line in block.group(1).splitlines(): + m = re.match(r"\s*(--[\w-]+)\s*:\s*([^;]+);", line) + if m: + found[m.group(1)] = m.group(2).strip().lower() + out[theme] = found + return out + + +def channel(value: int) -> float: + s = value / 255 + return s / 12.92 if s <= 0.03928 else ((s + 0.055) / 1.055) ** 2.4 + + +def luminance(hex_value: str) -> float: + h = hex_value.lstrip("#") + if len(h) == 3: + h = "".join(c * 2 for c in h) + r, g, b = (int(h[i : i + 2], 16) for i in (0, 2, 4)) + return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b) + + +def contrast(a: str, b: str) -> float: + la, lb = luminance(a), luminance(b) + hi, lo = max(la, lb), min(la, lb) + return (hi + 0.05) / (lo + 0.05) + + +def check_pair(theme: str, vars_: dict[str, str], fg: str, bg: str, minimum: float) -> None: + a, b = vars_.get(fg), vars_.get(bg) + if not a or not b: + return # a missing token is already reported by the completeness check + if not (a.startswith("#") and b.startswith("#")): + failures.append( + f"{theme}: {fg} on {bg} is not a hex pair ({a} on {b}), contrast unverifiable" + ) + return + ratio = contrast(a, b) + if ratio + 1e-9 < minimum: + failures.append( + f"{theme}: {fg} on {bg} is {ratio:.2f}:1, needs {minimum}:1 ({a} on {b})" + ) + + +def css_block(css: str, selector: str) -> str | None: + """The declaration body of the rule whose selector list contains + `selector` exactly (so .lane does not match .lane-confirmed). Comments + are stripped first: this file documents every rule, and a comment sitting + above a selector would otherwise be read as part of it.""" + css = re.sub(r"/\*.*?\*/", "", css, flags=re.DOTALL) + for match in re.finditer(r"([^{}]+)\{([^}]*)\}", css): + selectors = [s.strip() for s in match.group(1).split(",")] + if selector in selectors: + return match.group(2) + return None + + +css_text = TOKENS_CSS.read_text(encoding="utf-8") +themes = parse_themes(css_text) + +# ---- 1. both layers complete in every theme -------------------------------- + +for theme in THEMES: + for token in CHROME_TOKENS + BOARD_TOKENS: + if token not in themes[theme]: + failures.append(f"{theme}: tokens.css is missing {token}") + +names = re.findall(r'html\[data-theme="([\w-]+)"\]', css_text) +if names != THEMES: + failures.append( + f"tokens.css theme keys are {names}, expected {THEMES} " + "(useTheme.ts names these three, pitch is the default)" + ) + +# --bg-stripe was a chrome token that every board surface read as if it were +# turf. Deleting it is what makes the mistake unrepeatable, so it must not +# come back. (The file's header comment still names it, hence the colon.) +if re.search(r"--bg-stripe[\w-]*\s*:", css_text): + failures.append( + "tokens.css redefines --bg-stripe: the board must not have a chrome " + "token to mistake for turf again (use --pitch-turf / --pitch-stripe)" + ) + +# ---- 2. the board cannot be recoupled to the chrome ------------------------ + +for theme in THEMES: + for board_token in BOARD_TOKENS: + value = themes[theme].get(board_token) + if value is None: + continue + if "var(" in value: + failures.append( + f"{theme}: {board_token} is defined as {value}. Board tokens take a " + "literal value per theme; an indirection is how --team-home ended " + "up meaning 'the accent' in the first place" + ) + for chrome_token in CHROME_COLOURS: + if value == themes[theme].get(chrome_token): + failures.append( + f"{theme}: {board_token} has the same value as {chrome_token} " + f"({value}). The pitch must not be able to follow the chrome" + ) + +pitch_markings = (BOARD_DIR / "PitchMarkings.tsx").read_text(encoding="utf-8") +referenced = re.findall(r"var\((--[\w-]+)[),]", pitch_markings) +if not referenced: + failures.append("PitchMarkings.tsx reads no tokens at all, which cannot be right") +for token in referenced: + if token not in BOARD_TOKENS: + failures.append( + f"PitchMarkings.tsx reads {token}, which is not a board token. " + "The pitch is drawn from the board layer only" + ) + +board_tokens_ts = (BOARD_DIR / "tokens.ts").read_text(encoding="utf-8") +fill_block = re.search(r"TOKEN_FILL[^=]*=\s*\{(.*?)\};", board_tokens_ts, re.DOTALL) +face_line = re.search(r"TOKEN_FACE\s*=\s*\"([^\"]+)\"", board_tokens_ts) +if not fill_block or not face_line: + failures.append("board/tokens.ts no longer exposes TOKEN_FILL and TOKEN_FACE") +else: + for value in re.findall(r"\"(var\([^\"]+\))\"", fill_block.group(1)) + [ + face_line.group(1) + ]: + if "," in value: + failures.append( + f"board/tokens.ts uses {value}: a fallback is a chrome token waiting " + "to be inherited. Board tokens are defined in every theme, so there " + "is nothing to fall back to" + ) + name = value[4:-1] + if name not in BOARD_TOKENS: + failures.append(f"board/tokens.ts paints tokens with {value}, not a board token") + +for filename, selectors in FOOTBALL_SELECTORS.items(): + css = (BOARD_DIR / filename).read_text(encoding="utf-8") + for selector in selectors: + body = css_block(css, selector) + if body is None: + failures.append(f"{filename}: rule for {selector} not found") + continue + for chrome_token in CHROME_COLOURS + ["--accent-ink"]: + if f"var({chrome_token})" in body: + failures.append( + f"{filename}: {selector} reads the chrome token {chrome_token}. " + "That is football, not chrome: use the board layer" + ) + +# ---- 3. contrast ----------------------------------------------------------- + +for theme in THEMES: + vars_ = themes[theme] + for fg, bg in TEXT_PAIRS: + check_pair(theme, vars_, fg, bg, 4.5) + for fg, bg in GRAPHIC_PAIRS: + check_pair(theme, vars_, fg, bg, 3.0) + + accent, red, text_red = ( + vars_.get("--accent"), + vars_.get("--red"), + vars_.get("--text-red"), + ) + if accent and red and accent == red: + failures.append(f"{theme}: --accent and --red are the same colour") + if accent and text_red and accent == text_red: + failures.append(f"{theme}: --accent and --text-red are the same colour") + if accent and red and accent.startswith("#") and red.startswith("#"): + if contrast(accent, red) < MIN_ACCENT_VS_RED: + failures.append( + f"{theme}: --accent and --red are {contrast(accent, red):.2f}:1 apart, " + f"needs {MIN_ACCENT_VS_RED}. Two reds that mean different things must " + "not look identical" + ) + if accent and text_red and accent.startswith("#") and text_red.startswith("#"): + if contrast(accent, text_red) < MIN_ACCENT_VS_TEXT_RED: + failures.append( + f"{theme}: --accent and --text-red are {contrast(accent, text_red):.2f}:1 " + f"apart, needs {MIN_ACCENT_VS_TEXT_RED}" + ) + +# Each theme has to be visibly its own theme. e2e/design-tokens.spec.ts +# asserts this in the browser; asserting it here too means a bad palette edit +# fails in milliseconds instead of after a full Playwright run, and a +# red-family palette makes these collisions easy to write by accident. +for token in ["--bg", "--accent", "--pitch-turf"]: + values = {themes[t].get(token) for t in THEMES} + if len(values) != len(THEMES): + failures.append(f"{token} is not distinct across the three themes: {sorted(values)}") + +if failures: + print("\n".join(failures)) + print(f"check-palette: FAILED, {len(failures)} problem(s)") + sys.exit(1) + +print( + "check-palette: two token layers intact across " + f"{len(THEMES)} themes, {len(TEXT_PAIRS)} text pairs at AA, " + f"{len(GRAPHIC_PAIRS)} graphical pairs at 3:1" +) diff --git a/scripts/seed.py b/scripts/seed.py index 4981227..53e255e 100644 --- a/scripts/seed.py +++ b/scripts/seed.py @@ -21,15 +21,21 @@ import app.models # noqa: E402 (registers every table on Base.metadata) from app.db import Base, SessionLocal, engine # noqa: E402 from app.models import ( # noqa: E402 + ArchetypeCombination, Formation, FormationKeystone, + FormationMatchup, + FormationPhase, Identity, LibraryItem, + PositionArchetype, PositionCode, Role, RoleClash, RoleSynergy, RondoZone, + RotationSystem, + UnitBalanceRule, ) # table name (matches each seed file's top-level "table") -> (model class, @@ -45,6 +51,17 @@ "formation_keystones": (FormationKeystone, ["formation_code", "slot"], []), "rondo_zones": (RondoZone, ["formation_code", "zone_key"], []), "identities": (Identity, ["code"], ["kind"]), + # Tactics Lab library tables (doc 06 section 3.1, T-102). Same + # `code`-keyed upsert as everything above; no team_id in sight. + "position_archetypes": (PositionArchetype, ["code"], []), + "archetype_combinations": (ArchetypeCombination, ["code"], []), + "unit_balance_rules": (UnitBalanceRule, ["code"], []), + # T-103. formation_phases and formation_matchups are the two library + # tables whose natural key is a pair rather than a `code`, same as + # formation_keystones and rondo_zones above. + "rotation_systems": (RotationSystem, ["code"], []), + "formation_phases": (FormationPhase, ["formation_code", "variant_code"], []), + "formation_matchups": (FormationMatchup, ["ours_code", "theirs_code"], []), } @@ -91,6 +108,24 @@ def upsert(session, model: type, key_fields: list[str], extra_fields: list[str], "identities_archetypes.json", "identities_reference_teams.json", "identities_cult_corner.json", + # Reference systems (doc 06 section 2.5) are identities, and + # formation_phases.reference_code is a foreign key into identities.code, + # so they load before any phase row can point at one. + "identities_reference_systems.json", + # position_archetypes before archetype_combinations because a + # combination's slots_json names archetype codes. There is no database + # FK between the two (slots_json is JSON, doc 06 section 3.1), so this + # order is for readability and for the validator's mental model, not + # to satisfy SQLite. + "position_archetypes.json", + "archetype_combinations.json", + "unit_balance_rules.json", + # rotation_systems before formation_phases because a phase's + # uses_rotations names rotation codes (JSON, so no database FK, but the + # validator resolves it and the reading order should match). + "rotation_systems.json", + "formation_phases.json", + "formation_matchups.json", ] @@ -154,6 +189,12 @@ def main() -> int: "formation_keystones": session.query(FormationKeystone).count(), "rondo_zones": session.query(RondoZone).count(), "identities": session.query(Identity).count(), + "position_archetypes": session.query(PositionArchetype).count(), + "archetype_combinations": session.query(ArchetypeCombination).count(), + "unit_balance_rules": session.query(UnitBalanceRule).count(), + "rotation_systems": session.query(RotationSystem).count(), + "formation_phases": session.query(FormationPhase).count(), + "formation_matchups": session.query(FormationMatchup).count(), } finally: session.close() diff --git a/scripts/validate_seeds.py b/scripts/validate_seeds.py index fd338f2..08bdb38 100644 --- a/scripts/validate_seeds.py +++ b/scripts/validate_seeds.py @@ -28,6 +28,10 @@ - animation slot references: doc 03 section 4.1, delegated to backend/app/specs.py's AnimationSpec so the rule lives in one place. - reference team five-part detail template: doc 03 section 5. + - Tactics Lab archetypes/combinations/balance rules (T-102): doc 06 + section 2.6 for the content rules (closed duty vocabulary, 2 to 3 key + attributes from the six, every combination states a cost, warning copy + reads as a check) and section 3.1 for the column shapes. """ from __future__ import annotations @@ -43,15 +47,110 @@ BACKEND = ROOT / "backend" sys.path.insert(0, str(BACKEND)) +from app.schemas import ATTRIBUTE_KEYS # noqa: E402 from app.specs import AnimationSpec, Trajectory # noqa: E402 from pydantic import ValidationError # noqa: E402 from typing import get_args # noqa: E402 EM_DASH = "—" BANNED_IDENTITY_PHRASES = ["correct", "right way", "off-identity"] -SOURCE_REF_RE = re.compile(r"^bible:") +# doc 03 section 7.7 traceability. Content transcribed from the Bible cites +# "bible:SECTION"; the Tactics Lab tables (T-102/T-103) are written from doc +# 06, which is a separate source document, so they cite "doc06:SECTION". A +# ref still has to name one of the two, never nothing. +SOURCE_REF_RE = re.compile(r"^(bible|doc06):") TRAJECTORY_VALUES = set(get_args(Trajectory)) +# --------------------------------------------------------------------------- +# Tactics Lab vocabularies (doc 06 section 2.6 / 3.1, T-102) +# --------------------------------------------------------------------------- + +# The six coach-rated attribute sliders, taken from the single existing +# source of that vocabulary (app/schemas.py ATTRIBUTE_KEYS, itself Bible +# 1.3 / app/models/roster.py PlayerAttribute) rather than copied, so a +# change there cannot leave this validator silently checking a stale list. +ATTRIBUTE_VOCABULARY = set(ATTRIBUTE_KEYS) + +# doc 06 section 2.6: "Slot families: gk, cb_central, cb_wide, fb, wb, six, +# eight, ten, wide_forward, nine." 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", +} + +# doc 06 section 3.1: "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." Hence a hard-closed set here. +DUTY_VOCABULARY = { + "tempo", "progression", "rest_defence", "width", "pin", + "box_threat", "press_trigger", +} + +UNIT_VOCABULARY = { + "midfield_three", "double_pivot", "front_three", "strike_pair", + "back_line", "wide_unit", "box_midfield", +} + +# Which slot families may appear in which unit. Catches a combination that +# puts a nine in a back line, which no other check would notice. +UNIT_SLOT_FAMILIES = { + "midfield_three": {"six", "eight"}, + "double_pivot": {"six"}, + "box_midfield": {"six", "eight", "ten"}, + "front_three": {"wide_forward", "nine"}, + "strike_pair": {"nine", "ten"}, + "back_line": {"cb_central", "cb_wide", "fb", "wb"}, + "wide_unit": {"fb", "wb", "wide_forward"}, +} + +# --------------------------------------------------------------------------- +# Tactics Lab part two: phases, rotations, matchups, rondo map (doc 06 +# sections 2.3/2.4/2.5/2.8 and 3.1, T-103) +# --------------------------------------------------------------------------- + +PHASE_VOCABULARY = {"in_possession", "out_of_possession", "rest_defence", "transition"} +# The variant codes doc 06 section 2.4 names. A reference-system variant +# uses its own "ref_..." code and states its phase explicitly, so only the +# named ones are pinned to a phase here. +STANDARD_VARIANT_PHASE = { + "in_possession": "in_possession", + "in_possession_alt": "in_possession", + "out_of_possession": "out_of_possession", + "out_of_possession_alt": "out_of_possession", + "rest_defence": "rest_defence", +} +ROTATION_FAMILIES = {"first_line", "pivot", "wide", "front_line"} +ROUTE_KINDS = {"through", "around", "over"} +ZONE_KINDS = {"polygon", "ball_relative_circle"} +RONDO_ZONE_KEYS = { + "first_line", "midfield_box", "flank_corridor_left", "flank_corridor_right", + "last_line", "counterpress_ring", +} +# rest_shape is "'3+2' | '2+3' | '4+1' | '5+2' | null" in doc 06 section +# 3.1, but section 2.4 also uses 4+2 and the reference systems need 3+1 and +# 2+4, so section 3.1's list is illustrative rather than closed. Shape +# rather than membership is what is worth enforcing: two counts that add up +# to no more than the ten outfield players. +REST_SHAPE_RE = re.compile(r"^([1-9])\+([1-9])$") +# doc 06 section 2.5 requires every reference-system card to name its +# rotations and its one honest risk line, and identities has no column for +# either. They live in core_idea behind these markers instead of inventing +# schema this ticket may not change. +REFERENCE_SYSTEM_MARKERS = ["Formation:", "Rotations:", "Risk:", "Provenance:"] + +RULE_KINDS = {"requires_duty", "max_duty", "max_same_archetype"} +SEVERITIES = {"note", "warning"} +FOOT_HINTS = {"same_side", "opposite_side", "either"} +WORK_RATES = {"low", "med", "high"} + +# doc 06 section 3.1: unit balance warning_copy "must read as a check not +# an error", and CLAUDE.md's "curate, never lock" principle says the same +# thing about identity copy. A coach may want the flagged combination on +# purpose, so the vocabulary of failure is banned outright. +BANNED_WARNING_WORDS = ["invalid", "illegal", "wrong", "forbidden", "not allowed", "error"] + # Free-text cross-reference tokenizers for fields that embed codes in prose # rather than as a structured list (role_clashes.trigger_expression, doc 03 # section 3: "code, name, trigger_expression, warning_copy"). Role/identity @@ -83,6 +182,39 @@ "code", "name", "tag_line", "core_idea", "youth_takeaway", "age_hint", "shape_render", "source_ref", "content_version", ] +# enables_pattern_codes / enables_rotation_codes are legitimately empty on +# plenty of archetypes (a coverer enables no pattern), so they are checked +# for resolvability below rather than for presence here. +ARCHETYPE_REQUIRED_FIELDS = [ + "code", "slot_family", "name", "definition", "key_attribute_keys", + "awr_default", "dwr_default", "duties_json", "needs_around_it", + "source_ref", "content_version", +] +COMBINATION_REQUIRED_FIELDS = [ + "code", "unit", "name", "slots_json", "what_it_gives", "what_it_costs", + "source_ref", "content_version", +] +BALANCE_RULE_REQUIRED_FIELDS = [ + "code", "unit", "rule_kind", "warning_copy", "severity", + "source_ref", "content_version", +] +# rest_shape, reference_code and uses_rotations are legitimately null or +# empty (a high block has no rest shape, most variants are not attributed, +# and plenty of shapes are reached without a named rotation), so they are +# checked for validity below rather than for presence here. +PHASE_REQUIRED_FIELDS = [ + "formation_code", "variant_code", "phase", "name", "shape_label", "blurb", + "positions_json", "trigger", "source_ref", "content_version", +] +ROTATION_SYSTEM_REQUIRED_FIELDS = [ + "code", "name", "family", "applies_to_formations", "produces_shape", "trigger", + "what_moves_json", "coaching_points_json", "risk", "animation_spec_json", + "source_ref", "content_version", +] +MATCHUP_REQUIRED_FIELDS = [ + "ours_code", "theirs_code", "our_edges_json", "their_edges_json", "route", + "route_kind", "source_ref", "content_version", +] errors: list[str] = [] @@ -261,6 +393,9 @@ def main() -> int: require(bool(extras.get(field)), f"{fname} {code}: extras_json missing '{field}'") formation_codes: set[str] = set() + # Every slot family any formation actually uses, checked against the + # seeded archetypes further down (T-110). + formation_slot_families: set[str] = set() if "formations.json" in files: formation_items = files["formations.json"]["items"] check_duplicates("formations.json", formation_items, lambda i: i["code"]) @@ -280,6 +415,27 @@ def main() -> int: f"formations.json {code}: positions_json slot '{slot.get('slot')}' " f"references unknown position_code '{pc}'", ) + # T-110 / doc 06 section 2.6: every slot declares its slot + # family, because position_code is too coarse to carry the + # football. It cannot separate a back three's outer defender + # (cb_wide) from its middle one (cb_central), nor a six from + # an eight when both are CM. app/units.py's crosswalk turns + # this field into unit membership, so a missing or drifted + # value silently removes a unit from the balance evaluation + # rather than failing loudly. Hence both halves of the check. + sf = slot.get("slot_family") + require( + sf is not None, + f"formations.json {code}: positions_json slot '{slot.get('slot')}' " + "is missing required field 'slot_family'", + ) + if sf is not None: + require( + sf in SLOT_FAMILIES, + f"formations.json {code}: positions_json slot '{slot.get('slot')}' " + f"slot_family '{sf}' not in {sorted(SLOT_FAMILIES)}", + ) + formation_slot_families.add(sf) if "formation_keystones.json" in files: keystone_items = files["formation_keystones.json"]["items"] @@ -314,6 +470,51 @@ def main() -> int: f"rondo_zones.json {key}: trains_pattern_codes references unknown code '{pc}'", ) + # doc 06 section 2.3 (T-103): six zones on every formation, and + # the counterpress ring is a ball-relative circle rather than a + # polygon, which is the whole teaching point of that zone. + require( + item.get("zone_key") in RONDO_ZONE_KEYS, + f"rondo_zones.json {key}: zone_key not in {sorted(RONDO_ZONE_KEYS)}", + ) + zone_kind = item.get("zone_kind") + require( + zone_kind in ZONE_KINDS, + f"rondo_zones.json {key}: zone_kind '{zone_kind}' not in {sorted(ZONE_KINDS)}", + ) + # canonical_rondo is the label shown when no opposition is + # placed. With opposition on the board the ratio is computed, + # never read from the seed (doc 06 section 2.3), so this field + # is a fallback and every row owes one. + require( + bool(item.get("canonical_rondo")), + f"rondo_zones.json {key}: missing canonical_rondo, the no-opposition fallback label", + ) + radius = item.get("radius") + if zone_kind == "ball_relative_circle": + require( + isinstance(radius, (int, float)) and radius > 0, + f"rondo_zones.json {key}: a ball_relative_circle zone needs a positive radius", + ) + else: + require( + radius is None, + f"rondo_zones.json {key}: a polygon zone must not carry a radius", + ) + + # Every formation carries the full set of six zones: a formation + # missing one renders a rondo map with a hole in it rather than an + # error, which is the kind of gap only a completeness check finds. + by_formation: dict[str, set[str]] = {} + for item in rondo_items: + by_formation.setdefault(item["formation_code"], set()).add(item["zone_key"]) + for fc in sorted(formation_codes): + missing = RONDO_ZONE_KEYS - by_formation.get(fc, set()) + require( + not missing, + f"rondo_zones.json {fc}: missing rondo zone(s) {sorted(missing)}", + ) + archetype_codes: set[str] = set() if "identities_archetypes.json" in files: archetype_codes = {item["code"] for item in files["identities_archetypes.json"]["items"]} @@ -365,7 +566,16 @@ def main() -> int: identity_codes: set[str] = set() identity_files = [ f - for f in ("identities_archetypes.json", "identities_reference_teams.json", "identities_cult_corner.json") + for f in ( + "identities_archetypes.json", + "identities_reference_teams.json", + "identities_cult_corner.json", + # doc 06 section 2.5 reference systems (T-103) are identities of + # kind 'reference_system', so every identity-wide rule above + # (required fields, tag_line length, "curate never lock" copy) + # applies to them without being restated. + "identities_reference_systems.json", + ) if f in files ] all_identity_items: list[dict] = [] @@ -490,6 +700,534 @@ def main() -> int: "(doc 03 section 3 comment)", ) + # ----------------------------------------------------------------- + # Tactics Lab: position_archetypes, archetype_combinations, + # unit_balance_rules (doc 06 section 2.6 / 3.1, T-102). + # ----------------------------------------------------------------- + + rotation_item_codes: set[str] = set() + if "rotations.json" in files: + rotation_item_codes = {item["code"] for item in files["rotations.json"]["items"]} + + # Two rotation namespaces exist from T-103 onward, and an archetype may + # legitimately enable either: the library rotations (R1, R12, R13) are + # movement patterns, while rotation_systems (rot_...) are structural + # rotations, "who changes job" (doc 06 section 2.5). T-102 seeded + # enables_rotation_codes against the library codes, which is true as + # written, so those references stay and the check below widens to cover + # both rather than silently rejecting one namespace or the other. The + # collision guard is what keeps the widening honest: if the two ever + # share a code, "resolves" would stop meaning one thing. + rotation_system_codes: set[str] = set() + if "rotation_systems.json" in files: + rotation_system_codes = {item["code"] for item in files["rotation_systems.json"]["items"]} + for shared in sorted(rotation_item_codes & rotation_system_codes): + errors.append( + f"rotation_systems.json {shared}: code collides with the library rotation of the same " + "code, so enables_rotation_codes could no longer resolve to one thing" + ) + any_rotation_codes = rotation_item_codes | rotation_system_codes + + position_archetype_codes: dict[str, str] = {} # code -> slot_family + if "position_archetypes.json" in files: + fname = "position_archetypes.json" + archetype_items = files[fname]["items"] + check_duplicates(fname, archetype_items, lambda i: i["code"]) + position_archetype_codes = {i["code"]: i.get("slot_family") for i in archetype_items} + + # T-110: the third half of the slot_family drift guard. A formation + # may only name a family that some archetype actually belongs to, + # otherwise doc 06 section 5.3's picker opens on an empty list for + # that slot and the coach has nothing to choose. + seeded_families = {sf for sf in position_archetype_codes.values() if sf} + for used in sorted(formation_slot_families - seeded_families): + errors.append( + f"formations.json: slot_family '{used}' is used by a formation slot but no " + f"{fname} row belongs to it, so that slot's archetype picker would be empty" + ) + + for item in archetype_items: + code = item["code"] + require_fields(fname, code, item, ARCHETYPE_REQUIRED_FIELDS) + check_source_ref(fname, code, item) + # Archetype copy names real players in exemplar_note, so it is + # held to the same "curate, never lock" standard as identity + # copy (doc 03 section 7.6, CLAUDE.md rule 6). + check_no_banned_identity_phrase_anywhere(fname, code, item) + + require( + item.get("slot_family") in SLOT_FAMILIES, + f"{fname} {code}: slot_family '{item.get('slot_family')}' not in {sorted(SLOT_FAMILIES)}", + ) + + # doc 06 section 3.1: "2 to 3 key_attribute_keys drawn strictly + # from the existing six". + attrs = item.get("key_attribute_keys") or [] + require( + 2 <= len(attrs) <= 3, + f"{fname} {code}: key_attribute_keys has {len(attrs)} entries, doc 06 section 3.1 " + "requires 2 to 3", + ) + for attr in attrs: + require( + attr in ATTRIBUTE_VOCABULARY, + f"{fname} {code}: key_attribute_keys '{attr}' is not one of the six attributes " + f"{sorted(ATTRIBUTE_VOCABULARY)}", + ) + require( + len(set(attrs)) == len(attrs), + f"{fname} {code}: key_attribute_keys repeats an attribute", + ) + + # Closed duty vocabulary: adding one is a spec change, not a + # seed change (doc 06 section 3.1). + duties = item.get("duties_json") or [] + for duty in duties: + require( + duty in DUTY_VOCABULARY, + f"{fname} {code}: duties_json '{duty}' not in the closed duty vocabulary " + f"{sorted(DUTY_VOCABULARY)}", + ) + require( + len(set(duties)) == len(duties), + f"{fname} {code}: duties_json repeats a duty", + ) + + foot = item.get("foot_hint") + require( + foot is None or foot in FOOT_HINTS, + f"{fname} {code}: foot_hint must be null or one of {sorted(FOOT_HINTS)}", + ) + for wr_field in ("awr_default", "dwr_default"): + require( + item.get(wr_field) in WORK_RATES, + f"{fname} {code}: {wr_field} must be low|med|high", + ) + + # "needs_around_it (free text, one line)". Non-empty is covered + # by require_fields; the word floor is what keeps filler like + # "good players" out, which the ticket calls out by name. + needs = item.get("needs_around_it") or "" + require( + word_count(needs) >= 5, + f"{fname} {code}: needs_around_it is {word_count(needs)} words, too thin to be a real " + "requirement", + ) + + for pc in item.get("enables_pattern_codes") or []: + require( + pc in pattern_codes, + f"{fname} {code}: enables_pattern_codes references unknown code '{pc}'", + ) + for rc in item.get("enables_rotation_codes") or []: + require( + rc in any_rotation_codes, + f"{fname} {code}: enables_rotation_codes references unknown rotation code '{rc}' " + "(neither a library rotation nor a rotation system)", + ) + + if "archetype_combinations.json" in files: + fname = "archetype_combinations.json" + combination_items = files[fname]["items"] + check_duplicates(fname, combination_items, lambda i: i["code"]) + + for item in combination_items: + code = item["code"] + # what_it_costs is in the required list, so an empty string or a + # missing key fails here: doc 06 section 3.1 marks it REQUIRED, + # for the same reason rotation_systems.risk is not nullable. + require_fields(fname, code, item, COMBINATION_REQUIRED_FIELDS) + check_source_ref(fname, code, item) + check_no_banned_identity_phrase_anywhere(fname, code, item) + + unit = item.get("unit") + require( + unit in UNIT_VOCABULARY, + f"{fname} {code}: unit '{unit}' not in {sorted(UNIT_VOCABULARY)}", + ) + + costs = item.get("what_it_costs") or "" + require( + word_count(costs) >= 5, + f"{fname} {code}: what_it_costs is {word_count(costs)} words, too thin to be a real cost", + ) + + for i, slot in enumerate(item.get("slots_json") or []): + label = f"{code}.slots_json[{i}]" + archetype_code = slot.get("archetype_code") + slot_family = slot.get("slot_family") + require( + archetype_code in position_archetype_codes, + f"{fname} {label}: archetype_code '{archetype_code}' does not exist in " + "position_archetypes.json", + ) + require( + slot_family in SLOT_FAMILIES, + f"{fname} {label}: slot_family '{slot_family}' not in {sorted(SLOT_FAMILIES)}", + ) + if archetype_code in position_archetype_codes: + require( + position_archetype_codes[archetype_code] == slot_family, + f"{fname} {label}: archetype '{archetype_code}' belongs to slot family " + f"'{position_archetype_codes[archetype_code]}', not '{slot_family}'", + ) + if unit in UNIT_SLOT_FAMILIES: + require( + slot_family in UNIT_SLOT_FAMILIES[unit], + f"{fname} {label}: slot family '{slot_family}' cannot appear in unit '{unit}'", + ) + + for fc in item.get("home_formations") or []: + require( + fc in formation_codes, + f"{fname} {code}: home_formations references unknown formation '{fc}'", + ) + + if "unit_balance_rules.json" in files: + fname = "unit_balance_rules.json" + rule_items = files[fname]["items"] + check_duplicates(fname, rule_items, lambda i: i["code"]) + + for item in rule_items: + code = item["code"] + require_fields(fname, code, item, BALANCE_RULE_REQUIRED_FIELDS) + check_source_ref(fname, code, item) + + require( + item.get("unit") in UNIT_VOCABULARY, + f"{fname} {code}: unit '{item.get('unit')}' not in {sorted(UNIT_VOCABULARY)}", + ) + rule_kind = item.get("rule_kind") + require( + rule_kind in RULE_KINDS, + f"{fname} {code}: rule_kind '{rule_kind}' not in {sorted(RULE_KINDS)}", + ) + require( + item.get("severity") in SEVERITIES, + f"{fname} {code}: severity must be note|warning", + ) + + duty = item.get("duty") + if rule_kind in ("requires_duty", "max_duty"): + require( + duty in DUTY_VOCABULARY, + f"{fname} {code}: duty '{duty}' not in the closed duty vocabulary " + f"{sorted(DUTY_VOCABULARY)}", + ) + elif rule_kind == "max_same_archetype": + require( + duty is None, + f"{fname} {code}: max_same_archetype counts repeated archetypes, so duty must be null", + ) + + if rule_kind == "requires_duty": + require( + isinstance(item.get("min_count"), int), + f"{fname} {code}: requires_duty needs an integer min_count", + ) + elif rule_kind in ("max_duty", "max_same_archetype"): + require( + isinstance(item.get("max_count"), int), + f"{fname} {code}: {rule_kind} needs an integer max_count", + ) + + # "coach-facing, must read as a check not an error" (doc 06 + # section 3.1). The engine may want the flagged combination. + copy_text = (item.get("warning_copy") or "").lower() + for banned in BANNED_WARNING_WORDS: + require( + banned not in copy_text, + f"{fname} {code}: warning_copy uses '{banned}', which reads as an error rather " + "than a check", + ) + require( + "check" in copy_text, + f"{fname} {code}: warning_copy never asks the coach to check anything, so it reads " + "as a verdict rather than a check", + ) + check_no_banned_identity_phrase_anywhere(fname, code, item) + + # ----------------------------------------------------------------- + # Tactics Lab part two: rotation_systems, formation_phases, + # formation_matchups, reference systems (doc 06 sections 2.3 to 2.8 + # and 3.1, T-103). + # ----------------------------------------------------------------- + + if "rotation_systems.json" in files: + fname = "rotation_systems.json" + rotation_items = files[fname]["items"] + check_duplicates(fname, rotation_items, lambda i: i["code"]) + + for item in rotation_items: + code = item["code"] + # `risk` sits in the required list, so an empty string or a + # missing key fails right here. doc 06 section 3.1: "risk + # REQUIRED, not nullable. A rotation without a stated cost + # fails the validator." + require_fields(fname, code, item, ROTATION_SYSTEM_REQUIRED_FIELDS) + check_source_ref(fname, code, item) + # exemplar_note names real players, so rotation copy is held to + # the same "curate, never lock" standard as identity copy. + check_no_banned_identity_phrase_anywhere(fname, code, item) + + require( + item.get("family") in ROTATION_FAMILIES, + f"{fname} {code}: family '{item.get('family')}' not in {sorted(ROTATION_FAMILIES)}", + ) + for fc in item.get("applies_to_formations") or []: + require( + fc in formation_codes, + f"{fname} {code}: applies_to_formations references unknown formation '{fc}'", + ) + + # A one-word cost is the marketing version of stating a cost, + # same word floor as archetype_combinations.what_it_costs. + risk = item.get("risk") or "" + require( + word_count(risk) >= 8, + f"{fname} {code}: risk is {word_count(risk)} words, too thin to be a real cost", + ) + + require( + bool(item.get("coaching_points_json")), + f"{fname} {code}: no coaching points, so the rotation teaches nothing", + ) + + spec = item.get("animation_spec_json") + validate_animation_spec(fname, code, "animation_spec_json", spec) + spec_slots = {s.get("slot") for s in (spec or {}).get("slots", [])} + require( + bool(spec) and (spec or {}).get("loop") is True, + f"{fname} {code}: a rotation's animation spec loops (doc 03 section 4.1)", + ) + + for i, move in enumerate(item.get("what_moves_json") or []): + label = f"{code}.what_moves_json[{i}]" + for field in ("slot", "from", "to", "becomes"): + require(bool(move.get(field)), f"{fname} {label}: missing '{field}'") + # The board plays what_moves_json through the same slots the + # animation spec defines, so a slot named in one and absent + # from the other is a rotation that cannot be animated. + require( + move.get("slot") in spec_slots, + f"{fname} {label}: slot '{move.get('slot')}' is not defined in animation_spec_json", + ) + for end in ("from", "to"): + point = move.get(end) or {} + for axis in ("x", "y"): + value = point.get(axis) + require( + isinstance(value, (int, float)) and 0 <= value <= 100, + f"{fname} {label}: {end}.{axis} must be a model coordinate 0 to 100", + ) + + profile = item.get("requires_profile_json") or {} + for slot, need in profile.items(): + label = f"{code}.requires_profile_json.{slot}" + for ac in need.get("archetypes") or []: + require( + ac in position_archetype_codes, + f"{fname} {label}: archetype '{ac}' does not exist in position_archetypes.json", + ) + for attr in need.get("attributes") or []: + require( + attr in ATTRIBUTE_VOCABULARY, + f"{fname} {label}: attribute '{attr}' is not one of the six", + ) + require( + need.get("foot") in (None, "L", "R"), + f"{fname} {label}: foot must be null, 'L' or 'R'", + ) + + # seeds/roles.json's standing convention. A null note claims + # nothing, which is the honest option when unsure. + note = item.get("exemplar_note") + require( + note is None or note.endswith("Not a licence: names are editorial reference points only."), + f"{fname} {code}: exemplar_note must end with the standing disclaimer or be null", + ) + + formation_slots: dict[str, dict[str, str]] = {} + if "formations.json" in files: + for item in files["formations.json"]["items"]: + formation_slots[item["code"]] = { + p["slot"]: p.get("position_code") for p in item.get("positions_json") or [] + } + + phase_reference_codes: set[str] = set() + if "formation_phases.json" in files: + fname = "formation_phases.json" + phase_items = files[fname]["items"] + check_duplicates(fname, phase_items, lambda i: f"{i['formation_code']}.{i['variant_code']}") + + for item in phase_items: + key = f"{item.get('formation_code')}.{item.get('variant_code')}" + require_fields(fname, key, item, PHASE_REQUIRED_FIELDS) + check_source_ref(fname, key, item) + check_no_banned_identity_phrase_anywhere(fname, key, item) + + require( + item.get("phase") in PHASE_VOCABULARY, + f"{fname} {key}: phase '{item.get('phase')}' not in {sorted(PHASE_VOCABULARY)}", + ) + expected_phase = STANDARD_VARIANT_PHASE.get(item.get("variant_code")) + require( + expected_phase is None or item.get("phase") == expected_phase, + f"{fname} {key}: variant_code implies phase '{expected_phase}' but the row says " + f"'{item.get('phase')}'", + ) + require( + word_count(item.get("blurb", "")) <= 25, + f"{fname} {key}: blurb is {word_count(item.get('blurb', ''))} words, over the " + "25-word limit", + ) + + fc = item.get("formation_code") + require(fc in formation_codes, f"{fname} {key}: unknown formation_code") + + # THE rule of this table (doc 06 section 3.1): the morph + # animation binds by slot, so a phase that adds, drops or + # renames a slot cannot animate, it can only teleport tokens. + if fc in formation_slots: + base = formation_slots[fc] + seeded = {p.get("slot"): p.get("position_code") for p in item.get("positions_json") or []} + extra = sorted(set(seeded) - set(base)) + missing = sorted(set(base) - set(seeded)) + require( + not extra, + f"{fname} {key}: positions_json has slot(s) {extra} that the base formation " + "does not have", + ) + require( + not missing, + f"{fname} {key}: positions_json is missing base formation slot(s) {missing}", + ) + require( + len(item.get("positions_json") or []) == len(base), + f"{fname} {key}: positions_json must carry all {len(base)} slots exactly once", + ) + # Slots never change identity across phases: the left back + # walking into midfield is still the left back. + for slot, pc in seeded.items(): + if slot in base: + require( + pc == base[slot], + f"{fname} {key}: slot '{slot}' is position_code '{pc}' here but " + f"'{base[slot]}' in the base formation", + ) + + for p in item.get("positions_json") or []: + for axis in ("x", "y"): + value = p.get(axis) + require( + isinstance(value, (int, float)) and 0 <= value <= 100, + f"{fname} {key}: slot '{p.get('slot')}' {axis} must be a model " + "coordinate 0 to 100", + ) + + rest_shape = item.get("rest_shape") + if rest_shape is not None: + match = REST_SHAPE_RE.match(rest_shape) + require( + match is not None, + f"{fname} {key}: rest_shape '{rest_shape}' must look like '3+2'", + ) + if match: + require( + int(match.group(1)) + int(match.group(2)) <= 10, + f"{fname} {key}: rest_shape '{rest_shape}' asks for more than ten " + "outfield players", + ) + + ref = item.get("reference_code") + if ref is not None: + phase_reference_codes.add(ref) + require( + ref in identity_codes, + f"{fname} {key}: reference_code '{ref}' is not an identity", + ) + for rc in item.get("uses_rotations") or []: + require( + rc in rotation_system_codes, + f"{fname} {key}: uses_rotations references unknown rotation system '{rc}'", + ) + + if "formation_matchups.json" in files: + fname = "formation_matchups.json" + matchup_items = files[fname]["items"] + check_duplicates(fname, matchup_items, lambda i: f"{i['ours_code']}.{i['theirs_code']}") + + for item in matchup_items: + ours = item.get("ours_code") + theirs = item.get("theirs_code") + key = f"{ours}.{theirs}" + require_fields(fname, key, item, MATCHUP_REQUIRED_FIELDS) + check_source_ref(fname, key, item) + check_no_banned_identity_phrase_anywhere(fname, key, item) + + require(ours in formation_codes, f"{fname} {key}: unknown ours_code") + require(theirs in formation_codes, f"{fname} {key}: unknown theirs_code") + # doc 06 section 3.1: normalised at seed time, so the pair is + # stored once rather than twice with drifting copy. + require( + isinstance(ours, str) and isinstance(theirs, str) and ours < theirs, + f"{fname} {key}: (ours_code, theirs_code) must be normalised with " + "ours_code < theirs_code", + ) + require( + item.get("route_kind") in ROUTE_KINDS, + f"{fname} {key}: route_kind '{item.get('route_kind')}' not in {sorted(ROUTE_KINDS)}", + ) + # doc 06 section 2.8's three-step read, in order: where our + # spare man is, where we are short, which route connects them. + # A card missing a step is a card that teaches a different + # thing from every other card. + require( + bool(item.get("our_edges_json")), + f"{fname} {key}: no our_edges_json, so step one of the read is missing", + ) + require( + bool(item.get("their_edges_json")), + f"{fname} {key}: no their_edges_json, so step two of the read is missing", + ) + + for fname in identity_files: + if files[fname].get("kind") != "reference_system": + continue + for item in files[fname]["items"]: + code = item["code"] + # doc 06 section 2.5: every card carries base formation, the + # phase variant it produces, rotations used, keystone profiles, + # a youth takeaway and one honest risk line. identities has no + # column for the rotations or the risk line, so core_idea + # carries them behind fixed markers. + core = item.get("core_idea", "") + require( + core.startswith("Formation:"), + f"{fname} {code}: reference system core_idea must lead with 'Formation:'", + ) + for marker in REFERENCE_SYSTEM_MARKERS: + require( + marker in core, + f"{fname} {code}: reference system core_idea is missing its '{marker}' line", + ) + require( + bool(item.get("keystone_roles_json")), + f"{fname} {code}: reference system names no keystone profiles", + ) + require( + item.get("formation_code") is not None, + f"{fname} {code}: reference system must name its base formation", + ) + # The phase variant a reference system produces IS a + # formation_phases row pointing back at it. Without one the + # card describes a shape nothing can render. + require( + code in phase_reference_codes, + f"{fname} {code}: no formation_phases row carries reference_code '{code}', so " + "the system names no phase variant", + ) + if errors: print("\n".join(errors)) print(f"validate-seeds: FAILED, {len(errors)} error(s)") diff --git a/seeds/archetype_combinations.json b/seeds/archetype_combinations.json new file mode 100644 index 0000000..453a0e4 --- /dev/null +++ b/seeds/archetype_combinations.json @@ -0,0 +1,300 @@ +{ + "content_version": "1.1.0", + "table": "archetype_combinations", + "note": "doc 06 section 2.6, the named combinations for each unit. Every row states what the pairing or trio gives AND what it costs: a library that lists only benefits is marketing, not coaching, so what_it_costs is required and the seed validator rejects an empty one. Some of these named combinations deliberately trip a unit_balance_rules row (the gegenpress trio has no tempo setter, the rotating trio has two). That is not a contradiction: the rules are checks, the cost line says the same thing in words, and doc 06 names both as good combinations anyway.", + "items": [ + { + "code": "mt_metronome_creator_crasher", + "unit": "midfield_three", + "name": "Control, Unlock, Finish", + "slots_json": [ + {"slot_family": "six", "archetype_code": "six_metronome"}, + {"slot_family": "eight", "archetype_code": "eight_half_space_creator"}, + {"slot_family": "eight", "archetype_code": "eight_box_crasher"} + ], + "what_it_gives": "The positional-possession trio. One player controls the tempo, one unlocks the last line, one arrives from deep to finish the move.", + "what_it_costs": "The crasher's flank is exposed on the turnover, because he is inside their box at the moment the ball changes hands.", + "reference_note": "The trio positional-possession sides keep returning to. Not a licence: names are editorial reference points only.", + "home_formations": ["433"], + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "mt_destroyer_carrier_winner", + "unit": "midfield_three", + "name": "The Gegenpress Trio", + "slots_json": [ + {"slot_family": "six", "archetype_code": "six_destroyer"}, + {"slot_family": "eight", "archetype_code": "eight_carrier"}, + {"slot_family": "eight", "archetype_code": "eight_ball_winner"} + ], + "what_it_gives": "Wins the ball high and drives at a defence that has not had time to set itself.", + "what_it_costs": "Limited against a low block, because carrying into a packed box is not a plan. Nobody in the trio sets a tempo, so it struggles to control a game it is already leading.", + "reference_note": "Liverpool in the 2018 to 2020 window. Not a licence: names are editorial reference points only.", + "home_formations": ["433"], + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "mt_breaker_rotator_crasher", + "unit": "midfield_three", + "name": "Double Pivot By Rotation", + "slots_json": [ + {"slot_family": "six", "archetype_code": "six_line_breaker"}, + {"slot_family": "eight", "archetype_code": "eight_deep_rotator"}, + {"slot_family": "eight", "archetype_code": "eight_box_crasher"} + ], + "what_it_gives": "Builds as a two and attacks as a two, changing shape by rotation rather than by substitution.", + "what_it_costs": "It demands very high tactical discipline about who drops. Two of the three can set tempo, so they have to agree who accelerates once the line is broken.", + "reference_note": null, + "home_formations": ["433", "352"], + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "mt_metronome_winner_creator", + "unit": "midfield_three", + "name": "The Tournament Trio", + "slots_json": [ + {"slot_family": "six", "archetype_code": "six_metronome"}, + {"slot_family": "eight", "archetype_code": "eight_ball_winner"}, + {"slot_family": "eight", "archetype_code": "eight_half_space_creator"} + ], + "what_it_gives": "Balanced across all three midfield duties: tempo, rest defence, and progression, with no obvious hole to attack.", + "what_it_costs": "Nobody drives with the ball, so the last twenty metres depend entirely on the front three.", + "reference_note": null, + "home_formations": ["433", "352"], + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "dp_metronome_destroyer", + "unit": "double_pivot", + "name": "Screen and Circulate", + "slots_json": [ + {"slot_family": "six", "archetype_code": "six_metronome"}, + {"slot_family": "six", "archetype_code": "six_destroyer"} + ], + "what_it_gives": "One holds the ball, one holds the space. The pair is hard to play through and always offers a safe pass backwards.", + "what_it_costs": "Neither of them breaks a line, so the ten and the wide forwards have to create everything on their own.", + "reference_note": "The default 4-2-3-1 pairing. Bible 4.2 names the failure case, two destroyers together, as the sterile pivot.", + "home_formations": ["4231", "442"], + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "dp_breaker_shuttler", + "unit": "double_pivot", + "name": "Playmaker and Runner", + "slots_json": [ + {"slot_family": "six", "archetype_code": "six_line_breaker"}, + {"slot_family": "six", "archetype_code": "six_shuttler"} + ], + "what_it_gives": "One picks the vertical pass, one runs past the ball into the box, so the pivot both builds the attack and arrives in it.", + "what_it_costs": "Neither of them is a screen. Once the shuttler goes and the line breaker steps up with the ball, the counter comes straight back through the middle.", + "reference_note": null, + "home_formations": ["4231", "442"], + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "dp_carrier_destroyer", + "unit": "double_pivot", + "name": "Carry and Screen", + "slots_json": [ + {"slot_family": "six", "archetype_code": "six_carrier"}, + {"slot_family": "six", "archetype_code": "six_destroyer"} + ], + "what_it_gives": "Beats the first press by carrying rather than passing, with a screen sitting behind the carry the whole time.", + "what_it_costs": "There is no tempo setter in the pair, so possession is either forward or lost. Against a set block it will look rushed.", + "reference_note": null, + "home_formations": ["4231", "442"], + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "ft_winger_runner_inside", + "unit": "front_three", + "name": "Width, Depth, and a Cut Inside", + "slots_json": [ + {"slot_family": "wide_forward", "archetype_code": "wf_touchline_winger"}, + {"slot_family": "nine", "archetype_code": "nine_runner"}, + {"slot_family": "wide_forward", "archetype_code": "wf_inside_forward"} + ], + "what_it_gives": "One holds the width, one holds the depth, one arrives in the box from the far half-space. The back four is stretched in both directions.", + "what_it_costs": "Only one of the three tracks back, so the fullback behind the inside forward is left alone on every turnover.", + "reference_note": null, + "home_formations": ["433", "343"], + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "ft_false_nine_front_three", + "unit": "front_three", + "name": "The False Nine Front Three", + "slots_json": [ + {"slot_family": "wide_forward", "archetype_code": "wf_channel_runner"}, + {"slot_family": "nine", "archetype_code": "nine_false"}, + {"slot_family": "wide_forward", "archetype_code": "wf_inside_forward"} + ], + "what_it_gives": "The nine drops to make a midfield overload and the wide forwards attack the space he leaves on the last line.", + "what_it_costs": "If the centre backs refuse to follow the nine, the team has a spare man in midfield and nobody in the box.", + "reference_note": "Barcelona under Guardiola. Not a licence: names are editorial reference points only.", + "home_formations": ["433"], + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "ft_pressing_front_three", + "unit": "front_three", + "name": "The Pressing Front Three", + "slots_json": [ + {"slot_family": "wide_forward", "archetype_code": "wf_pressing_winger"}, + {"slot_family": "nine", "archetype_code": "nine_pressing_forward"}, + {"slot_family": "wide_forward", "archetype_code": "wf_inside_forward"} + ], + "what_it_gives": "Sets the trap from the front, wins the ball inside forty metres of their goal, and finishes through the inside forward.", + "what_it_costs": "It is a fitness bet. Once the front three cannot repeat the sprints, the block sits ten metres deeper and the same players look passive.", + "reference_note": null, + "home_formations": ["433"], + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "sp_runner_and_target", + "unit": "strike_pair", + "name": "Runner and Target", + "slots_json": [ + {"slot_family": "nine", "archetype_code": "nine_runner"}, + {"slot_family": "nine", "archetype_code": "nine_target"} + ], + "what_it_gives": "One occupies the centre backs in the air and one occupies the space behind them, so the defence has no single reference point.", + "what_it_costs": "Neither of them drops into midfield, so a two-versus-three in the middle third is the standing price of the pair.", + "reference_note": null, + "home_formations": ["442", "352", "541"], + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "sp_two_runners", + "unit": "strike_pair", + "name": "Two Runners", + "slots_json": [ + {"slot_family": "nine", "archetype_code": "nine_runner"}, + {"slot_family": "nine", "archetype_code": "nine_runner"} + ], + "what_it_gives": "Both attack the space behind, which pushes a high line back thirty metres from the very first pass.", + "what_it_costs": "It needs a ten who can find them or an identity that plays long. Without one of those, the pair is isolated and the midfield is outnumbered.", + "reference_note": null, + "home_formations": ["442", "352"], + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "sp_false_and_poacher", + "unit": "strike_pair", + "name": "False and Poacher", + "slots_json": [ + {"slot_family": "nine", "archetype_code": "nine_false"}, + {"slot_family": "nine", "archetype_code": "nine_poacher"} + ], + "what_it_gives": "One drops off to build the chance, one stays on the last shoulder to finish it, and the centre backs have to split their attention.", + "what_it_costs": "The poacher touches the ball a handful of times a game. If the chances do not arrive, the team is playing with ten.", + "reference_note": null, + "home_formations": ["442", "352"], + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "bl_stopper_and_coverer", + "unit": "back_line", + "name": "Stopper and Coverer", + "slots_json": [ + {"slot_family": "cb_central", "archetype_code": "cb_stopper"}, + {"slot_family": "cb_central", "archetype_code": "cb_coverer"} + ], + "what_it_gives": "One meets the ball in front of the line and one defends the space behind it, which is the minimum a back four needs.", + "what_it_costs": "Neither is picked to build, so the first pass out has to come from the fullbacks or the goalkeeper.", + "reference_note": null, + "home_formations": ["433", "4231", "442"], + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "bl_builder_and_stopper", + "unit": "back_line", + "name": "Builder and Stopper", + "slots_json": [ + {"slot_family": "cb_central", "archetype_code": "cb_ball_player"}, + {"slot_family": "cb_central", "archetype_code": "cb_stopper"} + ], + "what_it_gives": "One breaks the first line with a pass and one defends on the front foot, which suits a side that presses high and plays out.", + "what_it_costs": "Neither is picked for pure recovery pace, so depth is defended by the goalkeeper or by a very disciplined offside line.", + "reference_note": null, + "home_formations": ["433", "4231"], + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "bl_stepping_back_three", + "unit": "back_line", + "name": "Stepping Back Three", + "slots_json": [ + {"slot_family": "cb_wide", "archetype_code": "cb_wide_stepper"}, + {"slot_family": "cb_central", "archetype_code": "cb_coverer"}, + {"slot_family": "cb_wide", "archetype_code": "cb_wide_carrier"} + ], + "what_it_gives": "One side of the three carries the ball out, the other follows his man, and the middle sweeps behind both of them.", + "what_it_costs": "It only holds up if the wingbacks drop into a five. If they stay high, the three is defending the full width of the pitch alone.", + "reference_note": null, + "home_formations": ["352", "343", "541"], + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "wu_overlap_pair", + "unit": "wide_unit", + "name": "Overlap Pair", + "slots_json": [ + {"slot_family": "fb", "archetype_code": "fb_overlapper"}, + {"slot_family": "wide_forward", "archetype_code": "wf_inside_forward"} + ], + "what_it_gives": "The winger comes inside and takes his marker with him, the fullback runs into the space outside and delivers from the byline.", + "what_it_costs": "Both end up ahead of the ball, so the flank is a corridor on the turnover. This is the double exposure the roster already warns about.", + "reference_note": null, + "home_formations": ["433", "4231"], + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "wu_invert_and_hold_width", + "unit": "wide_unit", + "name": "Inverted Fullback and Touchline Winger", + "slots_json": [ + {"slot_family": "fb", "archetype_code": "fb_inverter"}, + {"slot_family": "wide_forward", "archetype_code": "wf_touchline_winger"} + ], + "what_it_gives": "The fullback steps in to overload the middle and protect the counter, the winger holds the chalk and keeps their fullback pinned.", + "what_it_costs": "The winger is alone in his one-versus-one all game. If he does not beat his man, that flank produces nothing at all.", + "reference_note": null, + "home_formations": ["433", "4231"], + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "wu_wingback_and_inside", + "unit": "wide_unit", + "name": "Wingback and Inside Forward", + "slots_json": [ + {"slot_family": "wb", "archetype_code": "wb_flyer"}, + {"slot_family": "wide_forward", "archetype_code": "wf_inside_forward"} + ], + "what_it_gives": "The wingback supplies the width and the depth on his own, and the wide forward plays inside him in the half-space.", + "what_it_costs": "One player covers the whole flank in both directions, which is a bet on his fitness and on his recovery pace.", + "reference_note": null, + "home_formations": ["343", "352"], + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + } + ] +} diff --git a/seeds/formation_matchups.json b/seeds/formation_matchups.json new file mode 100644 index 0000000..dbecf30 --- /dev/null +++ b/seeds/formation_matchups.json @@ -0,0 +1,247 @@ +{ + "content_version": "1.1.0", + "table": "formation_matchups", + "note": "doc 06 section 2.8. One row per unordered pair, normalised so ours_code <= theirs_code by string order, which is why every `route` line names the shape it is written from. The engine computes where the spare man is and where we are short numerically for any pair; these rows add the coached read on top, in doc 06's own three steps: our_edges_json is step one, their_edges_json is step two, and route plus route_kind is step three. Reference systems as pseudo-opponents are NOT seeded here: ours_code and theirs_code are foreign keys into formations.code, so a reference system cannot be an opponent without a schema change, which is out of scope for T-103.", + "items": [ + { + "ours_code": "343", + "theirs_code": "352", + "our_edges_json": [ + "Our back three is three against their two strikers, so the middle defender can carry into midfield with nobody assigned to him.", + "Our wide forwards start inside their wing backs, who cannot hold the width and mark them at the same time." + ], + "their_edges_json": [ + "Their three central midfielders outnumber our two, so the centre belongs to them until a wide forward drops in.", + "Each of our wing backs defends a full flank alone the moment theirs push on." + ], + "route": "The 3-4-3 goes around: the free centre back carries until a striker commits, then the ball is switched to the wing back running behind their wing back.", + "route_kind": "around", + "source_ref": "doc06:2.8", + "content_version": "1.1.0" + }, + { + "ours_code": "343", + "theirs_code": "4231", + "our_edges_json": [ + "Our back three is three against their lone nine, so one defender is always spare to carry out.", + "Our wing backs start beyond their wingers, who then have to track a whole flank or leave them free." + ], + "their_edges_json": [ + "Their double pivot plus their ten is three against our two central midfielders.", + "The space in front of our back three, where their ten receives, has nobody permanently responsible for it." + ], + "route": "The 3-4-3 goes around: the spare centre back carries until their winger steps in, and the wing back outside him is free for the cutback.", + "route_kind": "around", + "source_ref": "doc06:2.8", + "content_version": "1.1.0" + }, + { + "ours_code": "343", + "theirs_code": "433", + "our_edges_json": [ + "Our wing backs are free whenever their wingers stay high, because their fullbacks are already occupied by our wide forwards.", + "Their single pivot cannot screen both half-spaces, so one of our central midfielders receives on the turn." + ], + "their_edges_json": [ + "Their midfield three is three against our two, so we lose the centre unless a wide forward drops in.", + "Their wingers attack the space behind our advanced wing backs, which is this shape's standing weakness." + ], + "route": "The 3-4-3 goes through: fix their single pivot with one central midfielder, then play the half-space their stepping eight has already left.", + "route_kind": "through", + "source_ref": "doc06:2.8", + "content_version": "1.1.0" + }, + { + "ours_code": "343", + "theirs_code": "442", + "our_edges_json": [ + "Our back three is three against their two strikers, so a defender carries out with the ball every single time.", + "Our wide forwards sit inside their wide midfielders and outside their centre backs, in a zone their shape gives to nobody." + ], + "their_edges_json": [ + "Their two central midfielders match our two, so the centre is even and their banks stay compact.", + "Their two strikers pin our wide centre backs the moment our wing backs push on." + ], + "route": "The 3-4-3 goes through: the spare centre back carries past the first line, which pulls a central midfielder out and opens the half-space for the wide forward.", + "route_kind": "through", + "source_ref": "doc06:2.8", + "content_version": "1.1.0" + }, + { + "ours_code": "343", + "theirs_code": "541", + "our_edges_json": [ + "Our back three is three against one, so two defenders are permanently spare and can step into midfield unopposed.", + "Our wing backs meet a bank of four with no fullback behind it, so nobody picks them up until the byline." + ], + "their_edges_json": [ + "Their four across the middle is four against our two central midfielders inside the block.", + "Their five defenders cover the width of the box, so there is no space behind to run into." + ], + "route": "The 3-4-3 goes around: overload one side to drag the block across, then switch to the free wing back and attack the cutback rather than the cross.", + "route_kind": "around", + "source_ref": "doc06:2.8", + "content_version": "1.1.0" + }, + { + "ours_code": "352", + "theirs_code": "4231", + "our_edges_json": [ + "Our three centre backs face one striker, so two are spare and one can step into midfield with the ball.", + "Our three central midfielders are three against their double pivot until their ten drops in to help." + ], + "their_edges_json": [ + "Their wingers attack the space behind our wing backs, the known weakness of the shape.", + "Their ten sits between our midfield and our back three, where nobody is permanently responsible." + ], + "route": "The 3-5-2 goes through: a stepping centre back makes it four against two centrally, then the ball goes to a striker dropping off their pivot.", + "route_kind": "through", + "source_ref": "doc06:2.8", + "content_version": "1.1.0" + }, + { + "ours_code": "352", + "theirs_code": "433", + "our_edges_json": [ + "Our wing backs are free against their fullbacks whenever their wingers hold the touchline.", + "Our two strikers occupy both their centre backs, so their fullbacks cannot tuck in to help." + ], + "their_edges_json": [ + "Their wingers start beyond our wing backs, so a turnover is a three against three at best.", + "Their front three matches our back three, so we build without a spare man unless a midfielder drops in." + ], + "route": "The 3-5-2 goes around: drop a midfielder beside the centre backs to manufacture the spare man, then release the far wing back behind their winger.", + "route_kind": "around", + "source_ref": "doc06:2.8", + "content_version": "1.1.0" + }, + { + "ours_code": "352", + "theirs_code": "442", + "our_edges_json": [ + "Our three central midfielders are three against their two, the oldest overload in the book.", + "Our three centre backs face two strikers, so one of them is always free to carry." + ], + "their_edges_json": [ + "Their wide midfielders drop onto our wing backs, so our width costs two players to win one.", + "Their two strikers sit on our wide centre backs the moment the middle one steps out." + ], + "route": "The 3-5-2 goes through: the free centre back carries until a central midfielder commits, then the third midfielder receives in the space he left.", + "route_kind": "through", + "source_ref": "doc06:2.8", + "content_version": "1.1.0" + }, + { + "ours_code": "352", + "theirs_code": "541", + "our_edges_json": [ + "Our three centre backs against their one striker leaves two spare, so we build with a permanent extra man.", + "Our two strikers pin their middle defenders, which stops the back five sliding as one unit." + ], + "their_edges_json": [ + "Their midfield four is four against our three inside the block.", + "There is no space behind a deep back five, so our wing backs run into a wall rather than a channel." + ], + "route": "The 3-5-2 goes around: walk the ball forward with the spare defenders, then attack the outside shoulder of their wide centre back with a striker.", + "route_kind": "around", + "source_ref": "doc06:2.8", + "content_version": "1.1.0" + }, + { + "ours_code": "4231", + "theirs_code": "433", + "our_edges_json": [ + "Our double pivot plus the ten is three against their three, and their single pivot cannot screen both half-spaces.", + "Our fullbacks are free whenever their wingers stay high, because their eights are occupied centrally." + ], + "their_edges_json": [ + "Their eights attack the two spaces beside our double pivot, which is this shape's standing weakness.", + "Our lone nine faces two centre backs with no partner to fix them." + ], + "route": "The 4-2-3-1 goes through: the ten receives on the blind side of their single pivot, the one player who cannot cover both half-spaces at once.", + "route_kind": "through", + "source_ref": "doc06:2.8", + "content_version": "1.1.0" + }, + { + "ours_code": "4231", + "theirs_code": "442", + "our_edges_json": [ + "Our double pivot plus the ten is three against their two central midfielders.", + "Their strikers cannot press two centre backs and screen both our pivots at the same time." + ], + "their_edges_json": [ + "Their two strikers occupy both our centre backs, so our pivots have to come and fetch the ball themselves.", + "Their banks of four stay compact, so the space we win is in front of them rather than inside them." + ], + "route": "The 4-2-3-1 goes through: split the centre backs to draw one striker, then the free pivot turns and finds the ten between their lines.", + "route_kind": "through", + "source_ref": "doc06:2.8", + "content_version": "1.1.0" + }, + { + "ours_code": "4231", + "theirs_code": "541", + "our_edges_json": [ + "Our four builders face one presser, so we hold three spare players before the ball has even moved.", + "Our fullbacks reach the byline unopposed until their wide midfielder drops all the way onto them." + ], + "their_edges_json": [ + "Their midfield four is four against our three in the centre of the block.", + "Our lone nine is one against three centre backs, with nobody running ahead of the ball." + ], + "route": "The 4-2-3-1 goes around: pull the block to one side, then switch and attack the cutback zone, because there is nothing behind a deep back five.", + "route_kind": "around", + "source_ref": "doc06:2.8", + "content_version": "1.1.0" + }, + { + "ours_code": "433", + "theirs_code": "442", + "our_edges_json": [ + "Our midfield three is three against their two, the cleanest central overload in the game.", + "Our wingers hold the width against their fullbacks while their wide midfielders start narrow beside their central pair." + ], + "their_edges_json": [ + "Their two strikers pin both our centre backs, so our pivot receives under real pressure every time.", + "The space behind our fullbacks is where their wide midfielders and strikers counter." + ], + "route": "The 4-3-3 goes through: one eight drops to draw a central midfielder out, then the other receives turned in the half-space he vacated.", + "route_kind": "through", + "source_ref": "doc06:2.8", + "content_version": "1.1.0" + }, + { + "ours_code": "433", + "theirs_code": "541", + "our_edges_json": [ + "Our back four plus the pivot faces one presser, so we can build with as many spare players as we want.", + "Their wing backs cannot mark our wingers and stay inside the back five at the same time." + ], + "their_edges_json": [ + "Their four across the middle is four against our three inside the block.", + "There is no space behind their last line, so runs in behind have nowhere to arrive." + ], + "route": "The 4-3-3 goes around: hold both wingers on the touchline to stretch the five, then attack the cutback from the corner of their box.", + "route_kind": "around", + "source_ref": "doc06:2.8", + "content_version": "1.1.0" + }, + { + "ours_code": "442", + "theirs_code": "541", + "our_edges_json": [ + "Our two strikers face three centre backs but occupy the middle two, which stops the block sliding cleanly.", + "Our fullbacks are free because their wide midfielders start narrow, beside their central pair." + ], + "their_edges_json": [ + "Their four in midfield matches our four, so we win no overload anywhere in the centre.", + "Their five defenders own the width of the box, so a cross meets numbers rather than space." + ], + "route": "The 4-4-2 goes over: fix the block with width, then attack the far post and the second ball, because there is no route through and little space around.", + "route_kind": "over", + "source_ref": "doc06:2.8", + "content_version": "1.1.0" + } + ] +} diff --git a/seeds/formation_phases.json b/seeds/formation_phases.json new file mode 100644 index 0000000..65c0b74 --- /dev/null +++ b/seeds/formation_phases.json @@ -0,0 +1,898 @@ +{ + "content_version": "1.1.0", + "table": "formation_phases", + "note": "doc 06 section 2.4. A phase variant is the same eleven slots at different coordinates: slot ids are identical to the base formation's in every row, which is what lets the morph animation bind by slot instead of teleporting tokens. Reference-system variants (doc 06 section 2.5) are additional rows carrying reference_code; the Inter shape is not duplicated because the 3-5-2's own in_possession_alt row is that shape and is attributed directly.", + "items": [ + { + "formation_code": "433", + "variant_code": "in_possession", + "phase": "in_possession", + "name": "3-2-5 (inverted left back)", + "shape_label": "3-2-5", + "blurb": "The left back tucks into the pivot, the right back becomes the third centre back, and five players hold the last line.", + "positions_json": [ + {"slot": "gk", "position_code": "GK", "x": 14, "y": 50}, + {"slot": "cb_l", "position_code": "CB", "x": 30, "y": 26}, + {"slot": "cb_r", "position_code": "CB", "x": 30, "y": 50}, + {"slot": "fb_r", "position_code": "FB", "x": 30, "y": 74}, + {"slot": "fb_l", "position_code": "FB", "x": 46, "y": 40}, + {"slot": "six", "position_code": "DM", "x": 46, "y": 60}, + {"slot": "w_l", "position_code": "W", "x": 86, "y": 6}, + {"slot": "eight_l", "position_code": "CM", "x": 82, "y": 30}, + {"slot": "st", "position_code": "ST", "x": 89, "y": 50}, + {"slot": "eight_r", "position_code": "CM", "x": 82, "y": 70}, + {"slot": "w_r", "position_code": "W", "x": 86, "y": 94} + ], + "trigger": "Goal kick or centre-back circulation against a two-striker press, when the pivot needs a partner to beat the first line.", + "rest_shape": "3+2", + "reference_code": null, + "uses_rotations": ["rot_invert_fb_pivot"], + "source_ref": "doc06:2.4", + "content_version": "1.1.0" + }, + { + "formation_code": "433", + "variant_code": "in_possession_alt", + "phase": "in_possession", + "name": "2-3-5 (both fullbacks advanced)", + "shape_label": "2-3-5", + "blurb": "The centre backs split, the six drops into a line of three with both advanced fullbacks, and five players stretch the last line.", + "positions_json": [ + {"slot": "gk", "position_code": "GK", "x": 14, "y": 50}, + {"slot": "cb_l", "position_code": "CB", "x": 26, "y": 32}, + {"slot": "cb_r", "position_code": "CB", "x": 26, "y": 68}, + {"slot": "fb_l", "position_code": "FB", "x": 44, "y": 14}, + {"slot": "six", "position_code": "DM", "x": 44, "y": 50}, + {"slot": "fb_r", "position_code": "FB", "x": 44, "y": 86}, + {"slot": "w_l", "position_code": "W", "x": 86, "y": 6}, + {"slot": "eight_l", "position_code": "CM", "x": 84, "y": 30}, + {"slot": "st", "position_code": "ST", "x": 90, "y": 50}, + {"slot": "eight_r", "position_code": "CM", "x": 84, "y": 70}, + {"slot": "w_r", "position_code": "W", "x": 86, "y": 94} + ], + "trigger": "Their front two will not press a split pair, so we take more width and accept a two-man rest defence.", + "rest_shape": "2+3", + "reference_code": null, + "uses_rotations": ["rot_invert_fb_pivot"], + "source_ref": "doc06:2.4", + "content_version": "1.1.0" + }, + { + "formation_code": "433", + "variant_code": "out_of_possession", + "phase": "out_of_possession", + "name": "4-1-4-1 high block", + "shape_label": "4-1-4-1", + "blurb": "The nine leads the press, the wingers pin their fullbacks, both eights step up, and the six screens the space behind them.", + "positions_json": [ + {"slot": "gk", "position_code": "GK", "x": 26, "y": 50}, + {"slot": "fb_l", "position_code": "FB", "x": 48, "y": 14}, + {"slot": "cb_l", "position_code": "CB", "x": 48, "y": 38}, + {"slot": "cb_r", "position_code": "CB", "x": 48, "y": 62}, + {"slot": "fb_r", "position_code": "FB", "x": 48, "y": 86}, + {"slot": "six", "position_code": "DM", "x": 58, "y": 50}, + {"slot": "w_l", "position_code": "W", "x": 68, "y": 12}, + {"slot": "eight_l", "position_code": "CM", "x": 68, "y": 36}, + {"slot": "eight_r", "position_code": "CM", "x": 68, "y": 64}, + {"slot": "w_r", "position_code": "W", "x": 68, "y": 88}, + {"slot": "st", "position_code": "ST", "x": 80, "y": 50} + ], + "trigger": "Their goal kick or a backward pass, when we want to force play into a touchline and win the ball high.", + "rest_shape": null, + "reference_code": null, + "uses_rotations": [], + "source_ref": "doc06:2.4", + "content_version": "1.1.0" + }, + { + "formation_code": "433", + "variant_code": "out_of_possession_alt", + "phase": "out_of_possession", + "name": "4-4-2 mid block", + "shape_label": "4-4-2", + "blurb": "One eight steps up beside the nine and the shape settles into two banks of four inside our own half.", + "positions_json": [ + {"slot": "gk", "position_code": "GK", "x": 15, "y": 50}, + {"slot": "fb_l", "position_code": "FB", "x": 32, "y": 12}, + {"slot": "cb_l", "position_code": "CB", "x": 32, "y": 38}, + {"slot": "cb_r", "position_code": "CB", "x": 32, "y": 62}, + {"slot": "fb_r", "position_code": "FB", "x": 32, "y": 88}, + {"slot": "w_l", "position_code": "W", "x": 48, "y": 12}, + {"slot": "six", "position_code": "DM", "x": 48, "y": 38}, + {"slot": "eight_r", "position_code": "CM", "x": 48, "y": 62}, + {"slot": "w_r", "position_code": "W", "x": 48, "y": 88}, + {"slot": "eight_l", "position_code": "CM", "x": 64, "y": 40}, + {"slot": "st", "position_code": "ST", "x": 64, "y": 60} + ], + "trigger": "We are protecting a lead, or facing a build-up we cannot press, so we drop off and defend the centre.", + "rest_shape": null, + "reference_code": null, + "uses_rotations": [], + "source_ref": "doc06:2.4", + "content_version": "1.1.0" + }, + { + "formation_code": "433", + "variant_code": "rest_defence", + "phase": "rest_defence", + "name": "3-2-5 rest defence (3+2)", + "shape_label": "3-2-5", + "blurb": "Three defenders and two pivots stay behind the ball while five attack, so a counter meets numbers before it meets space.", + "positions_json": [ + {"slot": "gk", "position_code": "GK", "x": 22, "y": 50}, + {"slot": "cb_l", "position_code": "CB", "x": 42, "y": 28}, + {"slot": "cb_r", "position_code": "CB", "x": 42, "y": 50}, + {"slot": "fb_r", "position_code": "FB", "x": 42, "y": 72}, + {"slot": "fb_l", "position_code": "FB", "x": 56, "y": 40}, + {"slot": "six", "position_code": "DM", "x": 56, "y": 60}, + {"slot": "w_l", "position_code": "W", "x": 88, "y": 6}, + {"slot": "eight_l", "position_code": "CM", "x": 86, "y": 30}, + {"slot": "st", "position_code": "ST", "x": 92, "y": 50}, + {"slot": "eight_r", "position_code": "CM", "x": 86, "y": 70}, + {"slot": "w_r", "position_code": "W", "x": 88, "y": 94} + ], + "trigger": "The ball enters the final third and the five ahead of it commit, which is exactly when the five behind it must not.", + "rest_shape": "3+2", + "reference_code": null, + "uses_rotations": ["rot_invert_fb_pivot"], + "source_ref": "doc06:2.4", + "content_version": "1.1.0" + }, + { + "formation_code": "4231", + "variant_code": "in_possession", + "phase": "in_possession", + "name": "2-3-5 (splitting double pivot)", + "shape_label": "2-3-5", + "blurb": "One pivot drops to receive between split centre backs, the fullbacks take the last line, and the wingers move into the half-spaces.", + "positions_json": [ + {"slot": "gk", "position_code": "GK", "x": 16, "y": 50}, + {"slot": "cb_l", "position_code": "CB", "x": 28, "y": 28}, + {"slot": "cb_r", "position_code": "CB", "x": 28, "y": 72}, + {"slot": "dm_l", "position_code": "DM", "x": 42, "y": 36}, + {"slot": "am", "position_code": "AM", "x": 56, "y": 50}, + {"slot": "dm_r", "position_code": "DM", "x": 46, "y": 64}, + {"slot": "fb_l", "position_code": "FB", "x": 84, "y": 8}, + {"slot": "w_l", "position_code": "W", "x": 82, "y": 30}, + {"slot": "st", "position_code": "ST", "x": 88, "y": 50}, + {"slot": "w_r", "position_code": "W", "x": 82, "y": 70}, + {"slot": "fb_r", "position_code": "FB", "x": 84, "y": 92} + ], + "trigger": "Two strikers press our centre backs at a goal kick, so we need a third builder and our width from higher up the pitch.", + "rest_shape": "2+3", + "reference_code": null, + "uses_rotations": ["rot_double_pivot_split"], + "source_ref": "doc06:2.4", + "content_version": "1.1.0" + }, + { + "formation_code": "4231", + "variant_code": "in_possession_alt", + "phase": "in_possession", + "name": "3-2-5 (right back tucks inside)", + "shape_label": "3-2-5", + "blurb": "The right back tucks in as a third centre back, the double pivot stays whole, and the left back supplies all of the width.", + "positions_json": [ + {"slot": "gk", "position_code": "GK", "x": 14, "y": 50}, + {"slot": "cb_l", "position_code": "CB", "x": 30, "y": 28}, + {"slot": "cb_r", "position_code": "CB", "x": 30, "y": 50}, + {"slot": "fb_r", "position_code": "FB", "x": 30, "y": 74}, + {"slot": "dm_l", "position_code": "DM", "x": 46, "y": 38}, + {"slot": "dm_r", "position_code": "DM", "x": 46, "y": 62}, + {"slot": "fb_l", "position_code": "FB", "x": 84, "y": 8}, + {"slot": "w_l", "position_code": "W", "x": 82, "y": 30}, + {"slot": "st", "position_code": "ST", "x": 88, "y": 50}, + {"slot": "am", "position_code": "AM", "x": 82, "y": 70}, + {"slot": "w_r", "position_code": "W", "x": 84, "y": 92} + ], + "trigger": "Their press comes with one striker and a ten, so a back three plus a double pivot already beats the first line.", + "rest_shape": "3+2", + "reference_code": null, + "uses_rotations": [], + "source_ref": "doc06:2.4", + "content_version": "1.1.0" + }, + { + "formation_code": "4231", + "variant_code": "out_of_possession", + "phase": "out_of_possession", + "name": "4-4-2 mid block", + "shape_label": "4-4-2", + "blurb": "The ten joins the nine and the rest becomes two banks of four, the cleanest defensive conversion in the game.", + "positions_json": [ + {"slot": "gk", "position_code": "GK", "x": 15, "y": 50}, + {"slot": "fb_l", "position_code": "FB", "x": 32, "y": 12}, + {"slot": "cb_l", "position_code": "CB", "x": 32, "y": 38}, + {"slot": "cb_r", "position_code": "CB", "x": 32, "y": 62}, + {"slot": "fb_r", "position_code": "FB", "x": 32, "y": 88}, + {"slot": "w_l", "position_code": "W", "x": 48, "y": 12}, + {"slot": "dm_l", "position_code": "DM", "x": 48, "y": 38}, + {"slot": "dm_r", "position_code": "DM", "x": 48, "y": 62}, + {"slot": "w_r", "position_code": "W", "x": 48, "y": 88}, + {"slot": "am", "position_code": "AM", "x": 62, "y": 42}, + {"slot": "st", "position_code": "ST", "x": 62, "y": 58} + ], + "trigger": "We lose the ball in their half and settle into a mid block around the halfway line.", + "rest_shape": null, + "reference_code": null, + "uses_rotations": [], + "source_ref": "doc06:2.4", + "content_version": "1.1.0" + }, + { + "formation_code": "4231", + "variant_code": "out_of_possession_alt", + "phase": "out_of_possession", + "name": "4-2-3-1 high press", + "shape_label": "4-2-3-1", + "blurb": "The ten man-marks their deepest midfielder while the nine curves onto a centre back and the wingers jump their fullbacks.", + "positions_json": [ + {"slot": "gk", "position_code": "GK", "x": 26, "y": 50}, + {"slot": "fb_l", "position_code": "FB", "x": 50, "y": 14}, + {"slot": "cb_l", "position_code": "CB", "x": 50, "y": 40}, + {"slot": "cb_r", "position_code": "CB", "x": 50, "y": 60}, + {"slot": "fb_r", "position_code": "FB", "x": 50, "y": 86}, + {"slot": "dm_l", "position_code": "DM", "x": 60, "y": 38}, + {"slot": "dm_r", "position_code": "DM", "x": 60, "y": 62}, + {"slot": "w_l", "position_code": "W", "x": 72, "y": 16}, + {"slot": "am", "position_code": "AM", "x": 70, "y": 50}, + {"slot": "w_r", "position_code": "W", "x": 72, "y": 84}, + {"slot": "st", "position_code": "ST", "x": 82, "y": 50} + ], + "trigger": "Their pivot is the only route out of their build, so we take him away and press two centre backs with three players.", + "rest_shape": null, + "reference_code": null, + "uses_rotations": [], + "source_ref": "doc06:2.4", + "content_version": "1.1.0" + }, + { + "formation_code": "442", + "variant_code": "in_possession", + "phase": "in_possession", + "name": "2-4-4 (wide midfielders advanced)", + "shape_label": "2-4-4", + "blurb": "Both wide midfielders advance beside the strikers, so the shape attacks around or over rather than through a centre it leaves thin.", + "positions_json": [ + {"slot": "gk", "position_code": "GK", "x": 14, "y": 50}, + {"slot": "cb_l", "position_code": "CB", "x": 28, "y": 34}, + {"slot": "cb_r", "position_code": "CB", "x": 28, "y": 66}, + {"slot": "fb_l", "position_code": "FB", "x": 42, "y": 12}, + {"slot": "cm_l", "position_code": "CM", "x": 44, "y": 40}, + {"slot": "cm_r", "position_code": "CM", "x": 44, "y": 60}, + {"slot": "fb_r", "position_code": "FB", "x": 42, "y": 88}, + {"slot": "wm_l", "position_code": "W", "x": 82, "y": 8}, + {"slot": "st_l", "position_code": "ST", "x": 86, "y": 38}, + {"slot": "st_r", "position_code": "ST", "x": 86, "y": 62}, + {"slot": "wm_r", "position_code": "W", "x": 82, "y": 92} + ], + "trigger": "We are building against a block that defends the centre, so we take width early and accept the honest limits of the shape.", + "rest_shape": "2+2", + "reference_code": null, + "uses_rotations": [], + "source_ref": "doc06:2.4", + "content_version": "1.1.0" + }, + { + "formation_code": "442", + "variant_code": "out_of_possession", + "phase": "out_of_possession", + "name": "4-4-2 flat mid block", + "shape_label": "4-4-2", + "blurb": "Two banks of four hold their shape between the halfway line and the edge of our box, the reference defensive structure.", + "positions_json": [ + {"slot": "gk", "position_code": "GK", "x": 15, "y": 50}, + {"slot": "fb_l", "position_code": "FB", "x": 32, "y": 12}, + {"slot": "cb_l", "position_code": "CB", "x": 32, "y": 38}, + {"slot": "cb_r", "position_code": "CB", "x": 32, "y": 62}, + {"slot": "fb_r", "position_code": "FB", "x": 32, "y": 88}, + {"slot": "wm_l", "position_code": "W", "x": 50, "y": 12}, + {"slot": "cm_l", "position_code": "CM", "x": 50, "y": 38}, + {"slot": "cm_r", "position_code": "CM", "x": 50, "y": 62}, + {"slot": "wm_r", "position_code": "W", "x": 50, "y": 88}, + {"slot": "st_l", "position_code": "ST", "x": 66, "y": 40}, + {"slot": "st_r", "position_code": "ST", "x": 66, "y": 60} + ], + "trigger": "The opponent has settled possession in their own half and we defend the middle third.", + "rest_shape": null, + "reference_code": null, + "uses_rotations": [], + "source_ref": "doc06:2.4", + "content_version": "1.1.0" + }, + { + "formation_code": "442", + "variant_code": "out_of_possession_alt", + "phase": "out_of_possession", + "name": "4-4-2 low block", + "shape_label": "4-4-2", + "blurb": "Both banks sit within twenty five model units of our own goal, conceding the ball in order to protect the box.", + "positions_json": [ + {"slot": "gk", "position_code": "GK", "x": 4, "y": 50}, + {"slot": "fb_l", "position_code": "FB", "x": 12, "y": 18}, + {"slot": "cb_l", "position_code": "CB", "x": 12, "y": 40}, + {"slot": "cb_r", "position_code": "CB", "x": 12, "y": 60}, + {"slot": "fb_r", "position_code": "FB", "x": 12, "y": 82}, + {"slot": "wm_l", "position_code": "W", "x": 24, "y": 16}, + {"slot": "cm_l", "position_code": "CM", "x": 24, "y": 40}, + {"slot": "cm_r", "position_code": "CM", "x": 24, "y": 60}, + {"slot": "wm_r", "position_code": "W", "x": 24, "y": 84}, + {"slot": "st_l", "position_code": "ST", "x": 40, "y": 42}, + {"slot": "st_r", "position_code": "ST", "x": 40, "y": 58} + ], + "trigger": "We are defending a lead, or the opponent is stronger, so the pitch is made as small as we can make it.", + "rest_shape": null, + "reference_code": null, + "uses_rotations": [], + "source_ref": "doc06:2.4", + "content_version": "1.1.0" + }, + { + "formation_code": "442", + "variant_code": "rest_defence", + "phase": "rest_defence", + "name": "4-4-2 rest defence (4+2)", + "shape_label": "4-4-2", + "blurb": "The back four and both central midfielders stay behind the ball while the wide pair and the two strikers attack.", + "positions_json": [ + {"slot": "gk", "position_code": "GK", "x": 18, "y": 50}, + {"slot": "fb_l", "position_code": "FB", "x": 40, "y": 14}, + {"slot": "cb_l", "position_code": "CB", "x": 40, "y": 40}, + {"slot": "cb_r", "position_code": "CB", "x": 40, "y": 60}, + {"slot": "fb_r", "position_code": "FB", "x": 40, "y": 86}, + {"slot": "cm_l", "position_code": "CM", "x": 52, "y": 40}, + {"slot": "cm_r", "position_code": "CM", "x": 52, "y": 60}, + {"slot": "wm_l", "position_code": "W", "x": 84, "y": 8}, + {"slot": "st_l", "position_code": "ST", "x": 88, "y": 40}, + {"slot": "st_r", "position_code": "ST", "x": 88, "y": 60}, + {"slot": "wm_r", "position_code": "W", "x": 84, "y": 92} + ], + "trigger": "The ball reaches the final third down one side and the four plus two hold their positions rather than joining.", + "rest_shape": "4+2", + "reference_code": null, + "uses_rotations": [], + "source_ref": "doc06:2.4", + "content_version": "1.1.0" + }, + { + "formation_code": "352", + "variant_code": "in_possession", + "phase": "in_possession", + "name": "3-2-5 (wing backs at the last line)", + "shape_label": "3-2-5", + "blurb": "The wing backs take the last line, one midfielder drops beside the pivot, and the far midfielder arrives to complete a front five.", + "positions_json": [ + {"slot": "gk", "position_code": "GK", "x": 13, "y": 50}, + {"slot": "cb_l", "position_code": "CB", "x": 28, "y": 26}, + {"slot": "cb_c", "position_code": "CB", "x": 26, "y": 50}, + {"slot": "cb_r", "position_code": "CB", "x": 28, "y": 74}, + {"slot": "cm_l", "position_code": "CM", "x": 44, "y": 40}, + {"slot": "cm_c", "position_code": "CM", "x": 44, "y": 58}, + {"slot": "wb_l", "position_code": "WB", "x": 84, "y": 8}, + {"slot": "st_l", "position_code": "ST", "x": 88, "y": 34}, + {"slot": "st_r", "position_code": "ST", "x": 88, "y": 52}, + {"slot": "cm_r", "position_code": "CM", "x": 80, "y": 72}, + {"slot": "wb_r", "position_code": "WB", "x": 84, "y": 92} + ], + "trigger": "We have circulated to one side and their block has shifted, so the wing backs can hold the width unopposed.", + "rest_shape": "3+2", + "reference_code": null, + "uses_rotations": [], + "source_ref": "doc06:2.4", + "content_version": "1.1.0" + }, + { + "formation_code": "352", + "variant_code": "in_possession_alt", + "phase": "in_possession", + "name": "Asymmetric back four (the Inter shape)", + "shape_label": "4-3-3 asymmetric", + "blurb": "One wing back drops into a back four while the other holds the last line, so we build with four and attack with five.", + "positions_json": [ + {"slot": "gk", "position_code": "GK", "x": 13, "y": 50}, + {"slot": "wb_l", "position_code": "WB", "x": 30, "y": 12}, + {"slot": "cb_l", "position_code": "CB", "x": 28, "y": 34}, + {"slot": "cb_c", "position_code": "CB", "x": 26, "y": 55}, + {"slot": "cb_r", "position_code": "CB", "x": 30, "y": 78}, + {"slot": "cm_l", "position_code": "CM", "x": 46, "y": 34}, + {"slot": "cm_c", "position_code": "CM", "x": 44, "y": 52}, + {"slot": "cm_r", "position_code": "CM", "x": 60, "y": 68}, + {"slot": "wb_r", "position_code": "WB", "x": 82, "y": 92}, + {"slot": "st_l", "position_code": "ST", "x": 86, "y": 40}, + {"slot": "st_r", "position_code": "ST", "x": 86, "y": 58} + ], + "trigger": "We are building down the side of the high wing back against a back four, and want cover behind the far flank.", + "rest_shape": "4+2", + "reference_code": "ref_inter_asym_wb", + "uses_rotations": ["rot_wb_asymmetry"], + "source_ref": "doc06:2.5", + "content_version": "1.1.0" + }, + { + "formation_code": "352", + "variant_code": "out_of_possession", + "phase": "out_of_possession", + "name": "5-3-2", + "shape_label": "5-3-2", + "blurb": "Both wing backs drop into a back five and the three central midfielders screen the space in front of them.", + "positions_json": [ + {"slot": "gk", "position_code": "GK", "x": 12, "y": 50}, + {"slot": "wb_l", "position_code": "WB", "x": 28, "y": 8}, + {"slot": "cb_l", "position_code": "CB", "x": 28, "y": 28}, + {"slot": "cb_c", "position_code": "CB", "x": 28, "y": 50}, + {"slot": "cb_r", "position_code": "CB", "x": 28, "y": 72}, + {"slot": "wb_r", "position_code": "WB", "x": 28, "y": 92}, + {"slot": "cm_l", "position_code": "CM", "x": 45, "y": 32}, + {"slot": "cm_c", "position_code": "CM", "x": 45, "y": 50}, + {"slot": "cm_r", "position_code": "CM", "x": 45, "y": 68}, + {"slot": "st_l", "position_code": "ST", "x": 62, "y": 42}, + {"slot": "st_r", "position_code": "ST", "x": 62, "y": 58} + ], + "trigger": "We lose the ball, or their build reaches the halfway line, and the wing backs recover into the back line.", + "rest_shape": null, + "reference_code": null, + "uses_rotations": [], + "source_ref": "doc06:2.4", + "content_version": "1.1.0" + }, + { + "formation_code": "352", + "variant_code": "rest_defence", + "phase": "rest_defence", + "name": "3-2-5 rest defence (3+2)", + "shape_label": "3-2-5", + "blurb": "Three centre backs and two midfielders sit behind the ball, the most natural rest defence any formation gives you.", + "positions_json": [ + {"slot": "gk", "position_code": "GK", "x": 20, "y": 50}, + {"slot": "cb_l", "position_code": "CB", "x": 40, "y": 30}, + {"slot": "cb_c", "position_code": "CB", "x": 38, "y": 50}, + {"slot": "cb_r", "position_code": "CB", "x": 40, "y": 70}, + {"slot": "cm_c", "position_code": "CM", "x": 52, "y": 42}, + {"slot": "cm_l", "position_code": "CM", "x": 52, "y": 58}, + {"slot": "wb_l", "position_code": "WB", "x": 88, "y": 8}, + {"slot": "st_l", "position_code": "ST", "x": 90, "y": 38}, + {"slot": "st_r", "position_code": "ST", "x": 90, "y": 55}, + {"slot": "cm_r", "position_code": "CM", "x": 82, "y": 72}, + {"slot": "wb_r", "position_code": "WB", "x": 88, "y": 92} + ], + "trigger": "The ball is in the final third and the two strikers, both wing backs and the arriving midfielder are all ahead of it.", + "rest_shape": "3+2", + "reference_code": null, + "uses_rotations": [], + "source_ref": "doc06:2.4", + "content_version": "1.1.0" + }, + { + "formation_code": "343", + "variant_code": "in_possession", + "phase": "in_possession", + "name": "3-2-5 (wing backs are the width)", + "shape_label": "3-2-5", + "blurb": "The back three and the double pivot hold while the wing backs and the front three make five across the last line.", + "positions_json": [ + {"slot": "gk", "position_code": "GK", "x": 13, "y": 50}, + {"slot": "cb_l", "position_code": "CB", "x": 28, "y": 26}, + {"slot": "cb_c", "position_code": "CB", "x": 26, "y": 50}, + {"slot": "cb_r", "position_code": "CB", "x": 28, "y": 74}, + {"slot": "cm_l", "position_code": "CM", "x": 45, "y": 40}, + {"slot": "cm_r", "position_code": "CM", "x": 45, "y": 60}, + {"slot": "wb_l", "position_code": "WB", "x": 84, "y": 6}, + {"slot": "w_l", "position_code": "W", "x": 82, "y": 30}, + {"slot": "st", "position_code": "ST", "x": 88, "y": 50}, + {"slot": "w_r", "position_code": "W", "x": 82, "y": 70}, + {"slot": "wb_r", "position_code": "WB", "x": 84, "y": 94} + ], + "trigger": "We have settled possession and the wing backs can push on, because the back three does not need them to defend.", + "rest_shape": "3+2", + "reference_code": null, + "uses_rotations": [], + "source_ref": "doc06:2.4", + "content_version": "1.1.0" + }, + { + "formation_code": "343", + "variant_code": "in_possession_alt", + "phase": "in_possession", + "name": "3-2-2-3 (box midfield)", + "shape_label": "3-2-2-3", + "blurb": "Both wide forwards drop into the half-spaces to form the top of a box above the double pivot at its base.", + "positions_json": [ + {"slot": "gk", "position_code": "GK", "x": 13, "y": 50}, + {"slot": "cb_l", "position_code": "CB", "x": 28, "y": 26}, + {"slot": "cb_c", "position_code": "CB", "x": 26, "y": 50}, + {"slot": "cb_r", "position_code": "CB", "x": 28, "y": 74}, + {"slot": "cm_l", "position_code": "CM", "x": 44, "y": 40}, + {"slot": "cm_r", "position_code": "CM", "x": 44, "y": 60}, + {"slot": "w_l", "position_code": "W", "x": 62, "y": 34}, + {"slot": "w_r", "position_code": "W", "x": 62, "y": 66}, + {"slot": "wb_l", "position_code": "WB", "x": 82, "y": 8}, + {"slot": "st", "position_code": "ST", "x": 88, "y": 50}, + {"slot": "wb_r", "position_code": "WB", "x": 82, "y": 92} + ], + "trigger": "We are winning the centre and want to keep it, so the wide forwards come inside and the wing backs own the width.", + "rest_shape": "3+2", + "reference_code": null, + "uses_rotations": ["rot_box_form"], + "source_ref": "doc06:2.4", + "content_version": "1.1.0" + }, + { + "formation_code": "343", + "variant_code": "out_of_possession", + "phase": "out_of_possession", + "name": "5-4-1 press", + "shape_label": "5-4-1", + "blurb": "The wing backs drop to make a back five, the wide forwards join the midfield line, and the nine presses alone.", + "positions_json": [ + {"slot": "gk", "position_code": "GK", "x": 12, "y": 50}, + {"slot": "wb_l", "position_code": "WB", "x": 26, "y": 8}, + {"slot": "cb_l", "position_code": "CB", "x": 26, "y": 28}, + {"slot": "cb_c", "position_code": "CB", "x": 26, "y": 50}, + {"slot": "cb_r", "position_code": "CB", "x": 26, "y": 72}, + {"slot": "wb_r", "position_code": "WB", "x": 26, "y": 92}, + {"slot": "w_l", "position_code": "W", "x": 44, "y": 14}, + {"slot": "cm_l", "position_code": "CM", "x": 44, "y": 40}, + {"slot": "cm_r", "position_code": "CM", "x": 44, "y": 60}, + {"slot": "w_r", "position_code": "W", "x": 44, "y": 86}, + {"slot": "st", "position_code": "ST", "x": 62, "y": 50} + ], + "trigger": "Their possession settles, so we drop into a five and jump from it when a centre back takes a poor first touch.", + "rest_shape": null, + "reference_code": null, + "uses_rotations": [], + "source_ref": "doc06:2.4", + "content_version": "1.1.0" + }, + { + "formation_code": "343", + "variant_code": "rest_defence", + "phase": "rest_defence", + "name": "3-2-5 rest defence (3+2)", + "shape_label": "3-2-5", + "blurb": "The back three and the double pivot stay behind the ball while the wing backs and the front three attack it.", + "positions_json": [ + {"slot": "gk", "position_code": "GK", "x": 20, "y": 50}, + {"slot": "cb_l", "position_code": "CB", "x": 40, "y": 30}, + {"slot": "cb_c", "position_code": "CB", "x": 38, "y": 50}, + {"slot": "cb_r", "position_code": "CB", "x": 40, "y": 70}, + {"slot": "cm_l", "position_code": "CM", "x": 52, "y": 40}, + {"slot": "cm_r", "position_code": "CM", "x": 52, "y": 60}, + {"slot": "wb_l", "position_code": "WB", "x": 88, "y": 6}, + {"slot": "w_l", "position_code": "W", "x": 84, "y": 30}, + {"slot": "st", "position_code": "ST", "x": 92, "y": 50}, + {"slot": "w_r", "position_code": "W", "x": 84, "y": 70}, + {"slot": "wb_r", "position_code": "WB", "x": 88, "y": 94} + ], + "trigger": "The ball reaches the final third and five players are committed ahead of it, so the other five hold their line.", + "rest_shape": "3+2", + "reference_code": null, + "uses_rotations": [], + "source_ref": "doc06:2.4", + "content_version": "1.1.0" + }, + { + "formation_code": "541", + "variant_code": "in_possession", + "phase": "in_possession", + "name": "3-4-3 on the break", + "shape_label": "3-4-3", + "blurb": "Both wing backs launch forward and the wide midfielders join the striker, so the five is a five only briefly.", + "positions_json": [ + {"slot": "gk", "position_code": "GK", "x": 12, "y": 50}, + {"slot": "cb_l", "position_code": "CB", "x": 30, "y": 30}, + {"slot": "cb_c", "position_code": "CB", "x": 28, "y": 50}, + {"slot": "cb_r", "position_code": "CB", "x": 30, "y": 70}, + {"slot": "cm_cl", "position_code": "CM", "x": 48, "y": 40}, + {"slot": "cm_cr", "position_code": "CM", "x": 48, "y": 60}, + {"slot": "wb_l", "position_code": "WB", "x": 66, "y": 10}, + {"slot": "wb_r", "position_code": "WB", "x": 66, "y": 90}, + {"slot": "cm_l", "position_code": "CM", "x": 80, "y": 30}, + {"slot": "st", "position_code": "ST", "x": 86, "y": 50}, + {"slot": "cm_r", "position_code": "CM", "x": 80, "y": 70} + ], + "trigger": "We win the ball inside our block and the outlet striker holds it long enough for the wing backs to leave.", + "rest_shape": "3+2", + "reference_code": null, + "uses_rotations": [], + "source_ref": "doc06:2.4", + "content_version": "1.1.0" + }, + { + "formation_code": "541", + "variant_code": "out_of_possession", + "phase": "out_of_possession", + "name": "5-4-1 low block", + "shape_label": "5-4-1", + "blurb": "A back five and a midfield four defend the full width of the box, the reference park the bus shape.", + "positions_json": [ + {"slot": "gk", "position_code": "GK", "x": 4, "y": 50}, + {"slot": "wb_l", "position_code": "WB", "x": 12, "y": 14}, + {"slot": "cb_l", "position_code": "CB", "x": 12, "y": 32}, + {"slot": "cb_c", "position_code": "CB", "x": 12, "y": 50}, + {"slot": "cb_r", "position_code": "CB", "x": 12, "y": 68}, + {"slot": "wb_r", "position_code": "WB", "x": 12, "y": 86}, + {"slot": "cm_l", "position_code": "CM", "x": 24, "y": 18}, + {"slot": "cm_cl", "position_code": "CM", "x": 24, "y": 40}, + {"slot": "cm_cr", "position_code": "CM", "x": 24, "y": 60}, + {"slot": "cm_r", "position_code": "CM", "x": 24, "y": 82}, + {"slot": "st", "position_code": "ST", "x": 42, "y": 50} + ], + "trigger": "The opponent has the ball in our half and we defend deep by design rather than by accident.", + "rest_shape": null, + "reference_code": null, + "uses_rotations": [], + "source_ref": "doc06:2.4", + "content_version": "1.1.0" + }, + { + "formation_code": "541", + "variant_code": "rest_defence", + "phase": "rest_defence", + "name": "5-4-1 rest defence (5+2)", + "shape_label": "5-4-1", + "blurb": "Five defenders and two midfielders stay home, which is the plan rather than a compromise: we are not attacking with numbers.", + "positions_json": [ + {"slot": "gk", "position_code": "GK", "x": 10, "y": 50}, + {"slot": "wb_l", "position_code": "WB", "x": 30, "y": 10}, + {"slot": "cb_l", "position_code": "CB", "x": 30, "y": 30}, + {"slot": "cb_c", "position_code": "CB", "x": 30, "y": 50}, + {"slot": "cb_r", "position_code": "CB", "x": 30, "y": 70}, + {"slot": "wb_r", "position_code": "WB", "x": 30, "y": 90}, + {"slot": "cm_cl", "position_code": "CM", "x": 44, "y": 42}, + {"slot": "cm_cr", "position_code": "CM", "x": 44, "y": 58}, + {"slot": "cm_l", "position_code": "CM", "x": 62, "y": 26}, + {"slot": "st", "position_code": "ST", "x": 72, "y": 50}, + {"slot": "cm_r", "position_code": "CM", "x": 62, "y": 74} + ], + "trigger": "We have broken out and three players are ahead of the ball, so the remaining seven simply do not follow.", + "rest_shape": "5+2", + "reference_code": null, + "uses_rotations": [], + "source_ref": "doc06:2.4", + "content_version": "1.1.0" + }, + { + "formation_code": "433", + "variant_code": "ref_man_city_325", + "phase": "in_possession", + "name": "3-2-5, centre back into the pivot", + "shape_label": "3-2-5", + "blurb": "A centre back steps into midfield to make the pivot a two, both fullbacks stay in a back three, and five pin the last line.", + "positions_json": [ + {"slot": "gk", "position_code": "GK", "x": 14, "y": 50}, + {"slot": "fb_l", "position_code": "FB", "x": 30, "y": 22}, + {"slot": "cb_r", "position_code": "CB", "x": 28, "y": 50}, + {"slot": "fb_r", "position_code": "FB", "x": 30, "y": 78}, + {"slot": "cb_l", "position_code": "CB", "x": 46, "y": 40}, + {"slot": "six", "position_code": "DM", "x": 46, "y": 60}, + {"slot": "w_l", "position_code": "W", "x": 85, "y": 6}, + {"slot": "eight_l", "position_code": "CM", "x": 82, "y": 30}, + {"slot": "st", "position_code": "ST", "x": 89, "y": 50}, + {"slot": "eight_r", "position_code": "CM", "x": 82, "y": 70}, + {"slot": "w_r", "position_code": "W", "x": 85, "y": 94} + ], + "trigger": "Centre-back circulation against a two-striker press, when we want the free man to come from the back line rather than the flank.", + "rest_shape": "3+2", + "reference_code": "ref_man_city_325", + "uses_rotations": ["rot_cb_step"], + "source_ref": "doc06:2.5", + "content_version": "1.1.0" + }, + { + "formation_code": "433", + "variant_code": "ref_arsenal_316", + "phase": "in_possession", + "name": "3-1-6, dual eights in the half-spaces", + "shape_label": "3-1-6", + "blurb": "One fullback tucks into a back three and the other steps high, leaving a lone screener behind six attacking players.", + "positions_json": [ + {"slot": "gk", "position_code": "GK", "x": 16, "y": 50}, + {"slot": "fb_l", "position_code": "FB", "x": 30, "y": 14}, + {"slot": "cb_l", "position_code": "CB", "x": 30, "y": 34}, + {"slot": "cb_r", "position_code": "CB", "x": 30, "y": 58}, + {"slot": "six", "position_code": "DM", "x": 48, "y": 50}, + {"slot": "fb_r", "position_code": "FB", "x": 70, "y": 78}, + {"slot": "w_l", "position_code": "W", "x": 86, "y": 6}, + {"slot": "eight_l", "position_code": "CM", "x": 78, "y": 28}, + {"slot": "st", "position_code": "ST", "x": 90, "y": 50}, + {"slot": "eight_r", "position_code": "CM", "x": 78, "y": 68}, + {"slot": "w_r", "position_code": "W", "x": 86, "y": 94} + ], + "trigger": "Their block is already pinned deep and we are chasing a goal, so we buy bodies between the lines with rest defence.", + "rest_shape": "3+1", + "reference_code": "ref_arsenal_316", + "uses_rotations": ["rot_invert_fb_high", "rot_invert_fb_pivot"], + "source_ref": "doc06:2.5", + "content_version": "1.1.0" + }, + { + "formation_code": "343", + "variant_code": "ref_leverkusen_madrid_3421", + "phase": "in_possession", + "name": "3-4-2-1 into 3-2-5, squares in midfield", + "shape_label": "3-2-5", + "blurb": "The back three and double pivot both stay, the wing backs give the entire width, and the two forwards form squares in midfield.", + "positions_json": [ + {"slot": "gk", "position_code": "GK", "x": 13, "y": 50}, + {"slot": "cb_l", "position_code": "CB", "x": 28, "y": 26}, + {"slot": "cb_c", "position_code": "CB", "x": 26, "y": 50}, + {"slot": "cb_r", "position_code": "CB", "x": 28, "y": 74}, + {"slot": "cm_l", "position_code": "CM", "x": 44, "y": 40}, + {"slot": "cm_r", "position_code": "CM", "x": 44, "y": 60}, + {"slot": "wb_l", "position_code": "WB", "x": 84, "y": 6}, + {"slot": "w_l", "position_code": "W", "x": 72, "y": 32}, + {"slot": "st", "position_code": "ST", "x": 88, "y": 50}, + {"slot": "w_r", "position_code": "W", "x": 72, "y": 68}, + {"slot": "wb_r", "position_code": "WB", "x": 84, "y": 94} + ], + "trigger": "Settled possession in the middle third, when we want the carrier to have two forward options rather than one.", + "rest_shape": "3+2", + "reference_code": "ref_leverkusen_madrid_3421", + "uses_rotations": ["rot_box_form"], + "source_ref": "doc06:2.5", + "content_version": "1.1.0" + }, + { + "formation_code": "4231", + "variant_code": "ref_liverpool_235", + "phase": "in_possession", + "name": "2-3-5, one pivot drops and one steps", + "shape_label": "2-3-5", + "blurb": "The centre backs split, both fullbacks advance to the last line, and the double pivot works as a mechanism rather than a pair.", + "positions_json": [ + {"slot": "gk", "position_code": "GK", "x": 18, "y": 50}, + {"slot": "cb_l", "position_code": "CB", "x": 28, "y": 26}, + {"slot": "cb_r", "position_code": "CB", "x": 28, "y": 74}, + {"slot": "dm_l", "position_code": "DM", "x": 38, "y": 42}, + {"slot": "dm_r", "position_code": "DM", "x": 46, "y": 58}, + {"slot": "am", "position_code": "AM", "x": 58, "y": 50}, + {"slot": "fb_l", "position_code": "FB", "x": 84, "y": 8}, + {"slot": "w_l", "position_code": "W", "x": 82, "y": 28}, + {"slot": "st", "position_code": "ST", "x": 88, "y": 50}, + {"slot": "w_r", "position_code": "W", "x": 82, "y": 72}, + {"slot": "fb_r", "position_code": "FB", "x": 84, "y": 92} + ], + "trigger": "A goal kick or a settled build against a front two, when the double pivot can take turns dropping and stepping.", + "rest_shape": "2+3", + "reference_code": "ref_liverpool_235", + "uses_rotations": ["rot_double_pivot_split"], + "source_ref": "doc06:2.5", + "content_version": "1.1.0" + }, + { + "formation_code": "4231", + "variant_code": "ref_brighton_press_bait", + "phase": "in_possession", + "name": "Press baiting into 2-4-4", + "shape_label": "2-4-4", + "blurb": "Centre backs hold the ball dead to invite the first presser while both fullbacks step inside beside the double pivot.", + "positions_json": [ + {"slot": "gk", "position_code": "GK", "x": 18, "y": 50}, + {"slot": "cb_l", "position_code": "CB", "x": 24, "y": 36}, + {"slot": "cb_r", "position_code": "CB", "x": 24, "y": 64}, + {"slot": "fb_l", "position_code": "FB", "x": 42, "y": 24}, + {"slot": "dm_l", "position_code": "DM", "x": 42, "y": 42}, + {"slot": "dm_r", "position_code": "DM", "x": 42, "y": 58}, + {"slot": "fb_r", "position_code": "FB", "x": 42, "y": 76}, + {"slot": "w_l", "position_code": "W", "x": 84, "y": 8}, + {"slot": "am", "position_code": "AM", "x": 80, "y": 32}, + {"slot": "st", "position_code": "ST", "x": 88, "y": 58}, + {"slot": "w_r", "position_code": "W", "x": 84, "y": 90} + ], + "trigger": "Their first line hesitates on the edge of pressing, so we stop the ball and make them choose in front of us.", + "rest_shape": "2+4", + "reference_code": "ref_brighton_press_bait", + "uses_rotations": ["rot_press_bait_hold", "rot_invert_fb_pivot"], + "source_ref": "doc06:2.5", + "content_version": "1.1.0" + }, + { + "formation_code": "343", + "variant_code": "ref_amorim_343_invert", + "phase": "in_possession", + "name": "2-3-5, the middle centre back inverts", + "shape_label": "2-3-5", + "blurb": "The central defender of the three steps in front of the other two, giving the keeper a bounce option that does not exist wide.", + "positions_json": [ + {"slot": "gk", "position_code": "GK", "x": 16, "y": 50}, + {"slot": "cb_l", "position_code": "CB", "x": 28, "y": 30}, + {"slot": "cb_r", "position_code": "CB", "x": 28, "y": 70}, + {"slot": "cb_c", "position_code": "CB", "x": 44, "y": 50}, + {"slot": "cm_l", "position_code": "CM", "x": 46, "y": 32}, + {"slot": "cm_r", "position_code": "CM", "x": 46, "y": 68}, + {"slot": "wb_l", "position_code": "WB", "x": 84, "y": 6}, + {"slot": "w_l", "position_code": "W", "x": 80, "y": 30}, + {"slot": "st", "position_code": "ST", "x": 88, "y": 50}, + {"slot": "w_r", "position_code": "W", "x": 80, "y": 70}, + {"slot": "wb_r", "position_code": "WB", "x": 84, "y": 94} + ], + "trigger": "The keeper is under pressure from a front two and the wide passes are covered, so the bounce has to come centrally.", + "rest_shape": "2+3", + "reference_code": "ref_amorim_343_invert", + "uses_rotations": ["rot_cb_invert_middle"], + "source_ref": "doc06:2.5", + "content_version": "1.1.0" + }, + { + "formation_code": "4231", + "variant_code": "ref_como_split_pivot", + "phase": "in_possession", + "name": "2-3-5, splitting pivot and a high line", + "shape_label": "2-3-5", + "blurb": "The double pivot splits to create a back-three illusion, the wingers take the half-spaces, and the fullbacks own the touchline.", + "positions_json": [ + {"slot": "gk", "position_code": "GK", "x": 20, "y": 50}, + {"slot": "cb_l", "position_code": "CB", "x": 30, "y": 28}, + {"slot": "cb_r", "position_code": "CB", "x": 30, "y": 72}, + {"slot": "dm_l", "position_code": "DM", "x": 38, "y": 34}, + {"slot": "dm_r", "position_code": "DM", "x": 38, "y": 66}, + {"slot": "am", "position_code": "AM", "x": 54, "y": 50}, + {"slot": "fb_l", "position_code": "FB", "x": 78, "y": 6}, + {"slot": "w_l", "position_code": "W", "x": 76, "y": 30}, + {"slot": "st", "position_code": "ST", "x": 88, "y": 50}, + {"slot": "w_r", "position_code": "W", "x": 76, "y": 70}, + {"slot": "fb_r", "position_code": "FB", "x": 78, "y": 94} + ], + "trigger": "Settled possession against a mid block, with the defensive line held around forty metres so the pitch stays short.", + "rest_shape": "2+3", + "reference_code": "ref_como_split_pivot", + "uses_rotations": ["rot_double_pivot_split", "rot_fb_touchline_swap"], + "source_ref": "doc06:2.5", + "content_version": "1.1.0" + }, + { + "formation_code": "541", + "variant_code": "ref_low_block_counter", + "phase": "in_possession", + "name": "5-4-1 into 3-4-3 on the counter", + "shape_label": "3-4-3", + "blurb": "The block exists to make the pitch small, and the outlet striker who holds the first pass is the most important defender.", + "positions_json": [ + {"slot": "gk", "position_code": "GK", "x": 12, "y": 50}, + {"slot": "cb_l", "position_code": "CB", "x": 32, "y": 30}, + {"slot": "cb_c", "position_code": "CB", "x": 30, "y": 50}, + {"slot": "cb_r", "position_code": "CB", "x": 32, "y": 70}, + {"slot": "cm_cl", "position_code": "CM", "x": 50, "y": 42}, + {"slot": "cm_cr", "position_code": "CM", "x": 50, "y": 58}, + {"slot": "wb_l", "position_code": "WB", "x": 68, "y": 10}, + {"slot": "wb_r", "position_code": "WB", "x": 68, "y": 90}, + {"slot": "cm_l", "position_code": "CM", "x": 82, "y": 28}, + {"slot": "st", "position_code": "ST", "x": 88, "y": 50}, + {"slot": "cm_r", "position_code": "CM", "x": 82, "y": 72} + ], + "trigger": "We win the ball inside our own block and the striker holds it, which is the only trigger the wing backs need.", + "rest_shape": "3+2", + "reference_code": "ref_low_block_counter", + "uses_rotations": [], + "source_ref": "doc06:2.5", + "content_version": "1.1.0" + }, + { + "formation_code": "433", + "variant_code": "ref_barcelona_high_line", + "phase": "rest_defence", + "name": "The high line as rest defence", + "shape_label": "4-1-2-3", + "blurb": "Instead of leaving bodies behind the ball, leave none and hold an extreme offside line with the keeper sweeping behind it.", + "positions_json": [ + {"slot": "gk", "position_code": "GK", "x": 30, "y": 50}, + {"slot": "fb_l", "position_code": "FB", "x": 58, "y": 20}, + {"slot": "cb_l", "position_code": "CB", "x": 55, "y": 42}, + {"slot": "cb_r", "position_code": "CB", "x": 55, "y": 58}, + {"slot": "fb_r", "position_code": "FB", "x": 58, "y": 80}, + {"slot": "six", "position_code": "DM", "x": 62, "y": 50}, + {"slot": "eight_l", "position_code": "CM", "x": 74, "y": 32}, + {"slot": "eight_r", "position_code": "CM", "x": 74, "y": 68}, + {"slot": "w_l", "position_code": "W", "x": 88, "y": 6}, + {"slot": "st", "position_code": "ST", "x": 92, "y": 50}, + {"slot": "w_r", "position_code": "W", "x": 88, "y": 94} + ], + "trigger": "We have settled possession in their half and choose to defend the counter with a line and a keeper rather than with numbers.", + "rest_shape": "4+1", + "reference_code": "ref_barcelona_high_line", + "uses_rotations": ["rot_gk_plus_one"], + "source_ref": "doc06:2.5", + "content_version": "1.1.0" + } + ] +} diff --git a/seeds/formations.json b/seeds/formations.json index 7b23298..551565b 100644 --- a/seeds/formations.json +++ b/seeds/formations.json @@ -16,17 +16,17 @@ ], "natural_identities": ["positional_possession", "gegenpress"], "positions_json": [ - {"slot": "gk", "position_code": "GK", "x": 5, "y": 50}, - {"slot": "cb_l", "position_code": "CB", "x": 20, "y": 35}, - {"slot": "cb_r", "position_code": "CB", "x": 20, "y": 65}, - {"slot": "fb_l", "position_code": "FB", "x": 22, "y": 12}, - {"slot": "fb_r", "position_code": "FB", "x": 22, "y": 88}, - {"slot": "six", "position_code": "DM", "x": 42, "y": 50}, - {"slot": "eight_l", "position_code": "CM", "x": 55, "y": 30}, - {"slot": "eight_r", "position_code": "CM", "x": 55, "y": 70}, - {"slot": "w_l", "position_code": "W", "x": 78, "y": 15}, - {"slot": "st", "position_code": "ST", "x": 85, "y": 50}, - {"slot": "w_r", "position_code": "W", "x": 78, "y": 85} + {"slot": "gk", "position_code": "GK", "slot_family": "gk", "x": 5, "y": 50}, + {"slot": "cb_l", "position_code": "CB", "slot_family": "cb_central", "x": 20, "y": 35}, + {"slot": "cb_r", "position_code": "CB", "slot_family": "cb_central", "x": 20, "y": 65}, + {"slot": "fb_l", "position_code": "FB", "slot_family": "fb", "x": 22, "y": 12}, + {"slot": "fb_r", "position_code": "FB", "slot_family": "fb", "x": 22, "y": 88}, + {"slot": "six", "position_code": "DM", "slot_family": "six", "x": 42, "y": 50}, + {"slot": "eight_l", "position_code": "CM", "slot_family": "eight", "x": 55, "y": 30}, + {"slot": "eight_r", "position_code": "CM", "slot_family": "eight", "x": 55, "y": 70}, + {"slot": "w_l", "position_code": "W", "slot_family": "wide_forward", "x": 78, "y": 15}, + {"slot": "st", "position_code": "ST", "slot_family": "nine", "x": 85, "y": 50}, + {"slot": "w_r", "position_code": "W", "slot_family": "wide_forward", "x": 78, "y": 85} ], "source_ref": "bible:4.1", "content_version": "1.0.0" @@ -45,17 +45,17 @@ ], "natural_identities": ["counter_attack_pace", "gegenpress", "hybrid_transition_control"], "positions_json": [ - {"slot": "gk", "position_code": "GK", "x": 5, "y": 50}, - {"slot": "cb_l", "position_code": "CB", "x": 20, "y": 35}, - {"slot": "cb_r", "position_code": "CB", "x": 20, "y": 65}, - {"slot": "fb_l", "position_code": "FB", "x": 22, "y": 12}, - {"slot": "fb_r", "position_code": "FB", "x": 22, "y": 88}, - {"slot": "dm_l", "position_code": "DM", "x": 38, "y": 40}, - {"slot": "dm_r", "position_code": "DM", "x": 38, "y": 60}, - {"slot": "am", "position_code": "AM", "x": 60, "y": 50}, - {"slot": "w_l", "position_code": "W", "x": 72, "y": 15}, - {"slot": "w_r", "position_code": "W", "x": 72, "y": 85}, - {"slot": "st", "position_code": "ST", "x": 85, "y": 50} + {"slot": "gk", "position_code": "GK", "slot_family": "gk", "x": 5, "y": 50}, + {"slot": "cb_l", "position_code": "CB", "slot_family": "cb_central", "x": 20, "y": 35}, + {"slot": "cb_r", "position_code": "CB", "slot_family": "cb_central", "x": 20, "y": 65}, + {"slot": "fb_l", "position_code": "FB", "slot_family": "fb", "x": 22, "y": 12}, + {"slot": "fb_r", "position_code": "FB", "slot_family": "fb", "x": 22, "y": 88}, + {"slot": "dm_l", "position_code": "DM", "slot_family": "six", "x": 38, "y": 40}, + {"slot": "dm_r", "position_code": "DM", "slot_family": "six", "x": 38, "y": 60}, + {"slot": "am", "position_code": "AM", "slot_family": "ten", "x": 60, "y": 50}, + {"slot": "w_l", "position_code": "W", "slot_family": "wide_forward", "x": 72, "y": 15}, + {"slot": "w_r", "position_code": "W", "slot_family": "wide_forward", "x": 72, "y": 85}, + {"slot": "st", "position_code": "ST", "slot_family": "nine", "x": 85, "y": 50} ], "source_ref": "bible:4.2", "content_version": "1.0.0" @@ -74,17 +74,17 @@ ], "natural_identities": ["low_block_counter", "direct_second_ball"], "positions_json": [ - {"slot": "gk", "position_code": "GK", "x": 5, "y": 50}, - {"slot": "cb_l", "position_code": "CB", "x": 20, "y": 35}, - {"slot": "cb_r", "position_code": "CB", "x": 20, "y": 65}, - {"slot": "fb_l", "position_code": "FB", "x": 22, "y": 12}, - {"slot": "fb_r", "position_code": "FB", "x": 22, "y": 88}, - {"slot": "wm_l", "position_code": "W", "x": 50, "y": 15}, - {"slot": "cm_l", "position_code": "CM", "x": 50, "y": 38}, - {"slot": "cm_r", "position_code": "CM", "x": 50, "y": 62}, - {"slot": "wm_r", "position_code": "W", "x": 50, "y": 85}, - {"slot": "st_l", "position_code": "ST", "x": 80, "y": 40}, - {"slot": "st_r", "position_code": "ST", "x": 80, "y": 60} + {"slot": "gk", "position_code": "GK", "slot_family": "gk", "x": 5, "y": 50}, + {"slot": "cb_l", "position_code": "CB", "slot_family": "cb_central", "x": 20, "y": 35}, + {"slot": "cb_r", "position_code": "CB", "slot_family": "cb_central", "x": 20, "y": 65}, + {"slot": "fb_l", "position_code": "FB", "slot_family": "fb", "x": 22, "y": 12}, + {"slot": "fb_r", "position_code": "FB", "slot_family": "fb", "x": 22, "y": 88}, + {"slot": "wm_l", "position_code": "W", "slot_family": "wide_forward", "x": 50, "y": 15}, + {"slot": "cm_l", "position_code": "CM", "slot_family": "six", "x": 50, "y": 38}, + {"slot": "cm_r", "position_code": "CM", "slot_family": "six", "x": 50, "y": 62}, + {"slot": "wm_r", "position_code": "W", "slot_family": "wide_forward", "x": 50, "y": 85}, + {"slot": "st_l", "position_code": "ST", "slot_family": "nine", "x": 80, "y": 40}, + {"slot": "st_r", "position_code": "ST", "slot_family": "nine", "x": 80, "y": 60} ], "source_ref": "bible:4.3", "content_version": "1.0.0" @@ -103,17 +103,17 @@ ], "natural_identities": ["counter_attack_pace", "positional_possession"], "positions_json": [ - {"slot": "gk", "position_code": "GK", "x": 5, "y": 50}, - {"slot": "cb_l", "position_code": "CB", "x": 20, "y": 25}, - {"slot": "cb_c", "position_code": "CB", "x": 18, "y": 50}, - {"slot": "cb_r", "position_code": "CB", "x": 20, "y": 75}, - {"slot": "wb_l", "position_code": "WB", "x": 35, "y": 10}, - {"slot": "wb_r", "position_code": "WB", "x": 35, "y": 90}, - {"slot": "cm_l", "position_code": "CM", "x": 50, "y": 35}, - {"slot": "cm_c", "position_code": "CM", "x": 48, "y": 50}, - {"slot": "cm_r", "position_code": "CM", "x": 50, "y": 65}, - {"slot": "st_l", "position_code": "ST", "x": 82, "y": 40}, - {"slot": "st_r", "position_code": "ST", "x": 82, "y": 60} + {"slot": "gk", "position_code": "GK", "slot_family": "gk", "x": 5, "y": 50}, + {"slot": "cb_l", "position_code": "CB", "slot_family": "cb_wide", "x": 20, "y": 25}, + {"slot": "cb_c", "position_code": "CB", "slot_family": "cb_central", "x": 18, "y": 50}, + {"slot": "cb_r", "position_code": "CB", "slot_family": "cb_wide", "x": 20, "y": 75}, + {"slot": "wb_l", "position_code": "WB", "slot_family": "wb", "x": 35, "y": 10}, + {"slot": "wb_r", "position_code": "WB", "slot_family": "wb", "x": 35, "y": 90}, + {"slot": "cm_l", "position_code": "CM", "slot_family": "eight", "x": 50, "y": 35}, + {"slot": "cm_c", "position_code": "CM", "slot_family": "six", "x": 48, "y": 50}, + {"slot": "cm_r", "position_code": "CM", "slot_family": "eight", "x": 50, "y": 65}, + {"slot": "st_l", "position_code": "ST", "slot_family": "nine", "x": 82, "y": 40}, + {"slot": "st_r", "position_code": "ST", "slot_family": "nine", "x": 82, "y": 60} ], "source_ref": "bible:4.4", "content_version": "1.0.0" @@ -132,17 +132,17 @@ ], "natural_identities": ["gegenpress", "hybrid_transition_control"], "positions_json": [ - {"slot": "gk", "position_code": "GK", "x": 5, "y": 50}, - {"slot": "cb_l", "position_code": "CB", "x": 20, "y": 25}, - {"slot": "cb_c", "position_code": "CB", "x": 18, "y": 50}, - {"slot": "cb_r", "position_code": "CB", "x": 20, "y": 75}, - {"slot": "wb_l", "position_code": "WB", "x": 40, "y": 10}, - {"slot": "wb_r", "position_code": "WB", "x": 40, "y": 90}, - {"slot": "cm_l", "position_code": "CM", "x": 50, "y": 38}, - {"slot": "cm_r", "position_code": "CM", "x": 50, "y": 62}, - {"slot": "w_l", "position_code": "W", "x": 78, "y": 15}, - {"slot": "st", "position_code": "ST", "x": 85, "y": 50}, - {"slot": "w_r", "position_code": "W", "x": 78, "y": 85} + {"slot": "gk", "position_code": "GK", "slot_family": "gk", "x": 5, "y": 50}, + {"slot": "cb_l", "position_code": "CB", "slot_family": "cb_wide", "x": 20, "y": 25}, + {"slot": "cb_c", "position_code": "CB", "slot_family": "cb_central", "x": 18, "y": 50}, + {"slot": "cb_r", "position_code": "CB", "slot_family": "cb_wide", "x": 20, "y": 75}, + {"slot": "wb_l", "position_code": "WB", "slot_family": "wb", "x": 40, "y": 10}, + {"slot": "wb_r", "position_code": "WB", "slot_family": "wb", "x": 40, "y": 90}, + {"slot": "cm_l", "position_code": "CM", "slot_family": "six", "x": 50, "y": 38}, + {"slot": "cm_r", "position_code": "CM", "slot_family": "six", "x": 50, "y": 62}, + {"slot": "w_l", "position_code": "W", "slot_family": "wide_forward", "x": 78, "y": 15}, + {"slot": "st", "position_code": "ST", "slot_family": "nine", "x": 85, "y": 50}, + {"slot": "w_r", "position_code": "W", "slot_family": "wide_forward", "x": 78, "y": 85} ], "source_ref": "bible:4.5", "content_version": "1.0.0" @@ -161,17 +161,17 @@ ], "natural_identities": ["low_block_counter"], "positions_json": [ - {"slot": "gk", "position_code": "GK", "x": 5, "y": 50}, - {"slot": "wb_l", "position_code": "WB", "x": 20, "y": 8}, - {"slot": "cb_l", "position_code": "CB", "x": 15, "y": 28}, - {"slot": "cb_c", "position_code": "CB", "x": 13, "y": 50}, - {"slot": "cb_r", "position_code": "CB", "x": 15, "y": 72}, - {"slot": "wb_r", "position_code": "WB", "x": 20, "y": 92}, - {"slot": "cm_l", "position_code": "CM", "x": 45, "y": 25}, - {"slot": "cm_cl", "position_code": "CM", "x": 43, "y": 42}, - {"slot": "cm_cr", "position_code": "CM", "x": 43, "y": 58}, - {"slot": "cm_r", "position_code": "CM", "x": 45, "y": 75}, - {"slot": "st", "position_code": "ST", "x": 80, "y": 50} + {"slot": "gk", "position_code": "GK", "slot_family": "gk", "x": 5, "y": 50}, + {"slot": "wb_l", "position_code": "WB", "slot_family": "wb", "x": 20, "y": 8}, + {"slot": "cb_l", "position_code": "CB", "slot_family": "cb_wide", "x": 15, "y": 28}, + {"slot": "cb_c", "position_code": "CB", "slot_family": "cb_central", "x": 13, "y": 50}, + {"slot": "cb_r", "position_code": "CB", "slot_family": "cb_wide", "x": 15, "y": 72}, + {"slot": "wb_r", "position_code": "WB", "slot_family": "wb", "x": 20, "y": 92}, + {"slot": "cm_l", "position_code": "CM", "slot_family": "eight", "x": 45, "y": 25}, + {"slot": "cm_cl", "position_code": "CM", "slot_family": "six", "x": 43, "y": 42}, + {"slot": "cm_cr", "position_code": "CM", "slot_family": "six", "x": 43, "y": 58}, + {"slot": "cm_r", "position_code": "CM", "slot_family": "eight", "x": 45, "y": 75}, + {"slot": "st", "position_code": "ST", "slot_family": "nine", "x": 80, "y": 50} ], "source_ref": "bible:4.6", "content_version": "1.0.0" diff --git a/seeds/identities_reference_systems.json b/seeds/identities_reference_systems.json new file mode 100644 index 0000000..512520a --- /dev/null +++ b/seeds/identities_reference_systems.json @@ -0,0 +1,238 @@ +{ + "content_version": "1.1.0", + "table": "identities", + "kind": "reference_system", + "note": "doc 06 section 2.5's ten reference systems, seeded as identities so they inherit the existing curate-never-lock copy handling alongside the reference teams. doc 06 asks each card to carry base formation, the phase variant it produces, the rotations it uses, the keystone profiles it needs, one youth takeaway and one honest risk line. There are no columns on identities for a phase variant or a rotation list, and T-103 may not change the schema, so: the phase variant is the formation_phases row whose reference_code points back here (the validator refuses a reference system nothing points at), the keystone profiles are keystone_roles_json, and the rotations used and the risk line are named in core_idea, which the validator requires to carry a Formation, Rotations, Risk and Provenance line. Names are editorial reference points; identities curate, they never lock.", + "items": [ + { + "code": "ref_man_city_325", + "name": "Manchester City, 3-2-5 with the inverting centre back", + "tag_line": "A 4-3-3 on the team sheet that becomes a back three and a two-man pivot, with the pivot made by a defender.", + "formation_code": "433", + "core_idea": "Formation: 4-3-3 on the team sheet, 3-2-5 with the ball. A centre back steps into midfield to make the pivot a two, rather than a fullback, so the free man is manufactured from the middle of the back line. Both fullbacks stay home, one as the third defender and one as the width. The front five pins their back four, and rest defence is three plus two. Rotations: centre back steps into midfield, with the keeper available as the spare man when they press with an extra body. Risk: the defender who steps is the defender who is not there on the counter, so the line the free man comes from decides who covers the transition. Provenance: Pep Guardiola's Manchester City, roughly 2022 to 2024, with John Stones as the stepping defender.", + "signature_pattern_codes": ["B5", "B8", "A5"], + "keystone_roles_json": [ + {"role": "ball_playing_cb", "note": "the defender who steps into the pivot"}, + {"role": "single_pivot", "note": "the holding partner who never leaves"}, + {"role": "advanced_8", "note": "both eights at the last line in the half-spaces"}, + {"role": "touchline_winger", "note": "width held by both wide forwards"} + ], + "youth_takeaway": "The free man can be manufactured from any line. Ask your players which line theirs came from, and who is covering behind it.", + "age_hint": "U15+", + "block": "high", + "pass_risk_json": null, + "shape_render": "details_only", + "signature_animation_spec_json": null, + "static_shape_json": null, + "source_ref": "doc06:2.5", + "content_version": "1.1.0" + }, + { + "code": "ref_arsenal_316", + "name": "Arsenal, 3-2-5 into 3-1-6", + "tag_line": "One fullback tucks into the back three while the other steps into the pivot, so either of them can be the inverter.", + "formation_code": "433", + "core_idea": "Formation: 4-3-3 becoming 3-2-5, and 3-1-6 when the game demands it. One fullback tucks in to complete a back three while the other steps into the pivot, which frees both central midfielders to attack the half-spaces as dual eights. Redundancy is the point: either fullback can be the one who inverts, so the shape survives a marking scheme built to stop one of them. Rotations: inverted fullback into the pivot, and fullback into the eight line once their block is already pinned deep. Risk: the 3-1-6 leaves a single screener behind six attacking players, which is a shape for chasing a game rather than a default setting. Provenance: Mikel Arteta's Arsenal from 2022 onward, first through Zinchenko and later through Timber and Calafiori.", + "signature_pattern_codes": ["B5", "A5", "A2"], + "keystone_roles_json": [ + {"role": "inverted_fb", "note": "the fullback who steps into the pivot"}, + {"role": "defensive_fb", "note": "the fullback who tucks in as the third defender"}, + {"role": "advanced_8", "note": "dual eights attacking both half-spaces"}, + {"role": "single_pivot", "note": "the lone screener in the 3-1-6"} + ], + "youth_takeaway": "Build a shape two players can produce rather than one, and a marker cannot take it away by following a single player.", + "age_hint": "U15+", + "block": "high", + "pass_risk_json": null, + "shape_render": "details_only", + "signature_animation_spec_json": null, + "static_shape_json": null, + "source_ref": "doc06:2.5", + "content_version": "1.1.0" + }, + { + "code": "ref_leverkusen_madrid_3421", + "name": "Leverkusen and Real Madrid, 3-4-2-1 into 3-2-5", + "tag_line": "The back three and the double pivot both stay, and the entire width plus much of the goal threat comes from two wing backs.", + "formation_code": "343", + "core_idea": "Formation: 3-4-2-1 becoming 3-2-5 with the ball. The back three holds and the double pivot holds, so all of the width and much of the goal threat comes from the two wing backs. The build forms square structures in midfield, which means the player on the ball always has two forward options rather than one. Rotations: the two forwards drop to form the box midfield, keeping four central players above the defenders. Risk: two wing backs carrying the whole width for ninety minutes is a physical bill that arrives in the last twenty, and there is no cover behind either of them. Provenance: Xabi Alonso's Bayer Leverkusen from 2023, and the same principles carried to Real Madrid.", + "signature_pattern_codes": ["A5", "B3", "F1"], + "keystone_roles_json": [ + {"role": "wingback", "note": "both wing backs supply the entire width"}, + {"role": "wide_cb", "note": "the back three never breaks up"}, + {"role": "single_pivot", "note": "a double pivot that stays whole"}, + {"role": "inside_forward", "note": "the two forwards who form the box"} + ], + "youth_takeaway": "Width and goal threat can be the same two players, if you have those two players. Count how far they actually run before you ask.", + "age_hint": "U15+", + "block": "mid", + "pass_risk_json": null, + "shape_render": "details_only", + "signature_animation_spec_json": null, + "static_shape_json": null, + "source_ref": "doc06:2.5", + "content_version": "1.1.0" + }, + { + "code": "ref_liverpool_235", + "name": "Liverpool, 4-2-3-1 into 2-3-5", + "tag_line": "The centre backs split, both fullbacks advance, and one pivot drops while the other steps, so the pair is a mechanism.", + "formation_code": "4231", + "core_idea": "Formation: 4-2-3-1 becoming 2-3-5 with the ball. The centre backs split, both fullbacks advance to the last line, and one pivot drops while the other steps forward. Without the ball the shape compacts into a 4-3-3. Rotations: the splitting double pivot, where which of the two drops is decided by where the press is coming from rather than by the team sheet. Risk: when one pivot drops, his partner is alone against two central midfielders, so every second ball in the middle starts as a one against two. Provenance: Arne Slot's Liverpool from 2024, with Gravenberch and Mac Allister sharing the two jobs.", + "signature_pattern_codes": ["B5", "A4", "A5"], + "keystone_roles_json": [ + {"role": "single_pivot", "note": "the pivot who drops between the split defenders"}, + {"role": "box_to_box_8", "note": "the pivot who steps forward instead"}, + {"role": "overlapping_fb", "note": "both fullbacks advance to the last line"}, + {"role": "inside_forward", "note": "wingers come inside as the fullbacks pass them"} + ], + "youth_takeaway": "The double pivot is a mechanism, not a pair of positions. Both players should be able to do both halves of it.", + "age_hint": "U15+", + "block": "high", + "pass_risk_json": null, + "shape_render": "details_only", + "signature_animation_spec_json": null, + "static_shape_json": null, + "source_ref": "doc06:2.5", + "content_version": "1.1.0" + }, + { + "code": "ref_brighton_press_bait", + "name": "Brighton under De Zerbi, press baiting into 2-4-4", + "tag_line": "Centre backs hold the ball dead with the sole of the foot to keep every lane open and invite the first presser.", + "formation_code": "4231", + "core_idea": "Formation: 4-2-3-1 becoming 2-4-4 with the ball. The centre backs stop the ball dead under the sole of the foot, which keeps every passing lane open at once and invites the first presser to commit. The backward pass is a trigger for the team to move forward, not a retreat. Both fullbacks step inside beside the double pivot. Rotations: holding the ball dead to invite the presser, with both fullbacks inverting to fill the middle. Risk: this is a genuine risk taken on purpose in our own third, so it needs players who can execute under pressure, and they should be told that plainly rather than discover it in a match. Provenance: Roberto De Zerbi's Brighton, 2022 to 2024.", + "signature_pattern_codes": ["B9", "B8", "B5"], + "keystone_roles_json": [ + {"role": "ball_playing_cb", "note": "the defender who holds the ball dead"}, + {"role": "single_pivot", "note": "the bounce option in the space the presser leaves"}, + {"role": "inverted_fb", "note": "both fullbacks step inside beside the pivot"}, + {"role": "inside_forward", "note": "wingers wait between their fullback and centre back"} + ], + "youth_takeaway": "Pressure is information. You can choose when to receive it, but only if the three ways out are rehearsed before the bait is.", + "age_hint": "U16+", + "block": "high", + "pass_risk_json": null, + "shape_render": "details_only", + "signature_animation_spec_json": null, + "static_shape_json": null, + "source_ref": "doc06:2.5", + "content_version": "1.1.0" + }, + { + "code": "ref_inter_asym_wb", + "name": "Inter, 3-5-2 with asymmetric wing backs", + "tag_line": "One wing back high and one deep turns a back three into a back four in build and a front five in attack.", + "formation_code": "352", + "core_idea": "Formation: 3-5-2, a back four in build and a front five in attack. One wing back holds the last line while the other drops into the back line, so the team is fluid with the ball and rigid as a 5-3-2 without it. Rotations: asymmetric wing backs, with the side decided by where the ball is being built. Risk: the deep wing back is the only cover on his entire flank, so a switch away from the ball finds him alone against two. Provenance: Simone Inzaghi's Inter, roughly 2021 to 2024, with Dimarco high and Darmian tucked in.", + "signature_pattern_codes": ["R12", "B3", "F1"], + "keystone_roles_json": [ + {"role": "wingback", "note": "one holds the last line, one drops into the back four"}, + {"role": "wide_cb", "note": "the wide defender slides across to fullback"}, + {"role": "box_to_box_8", "note": "the midfielder who arrives to complete the five"}, + {"role": "target_man", "note": "a strike pair that moves off each other"} + ], + "youth_takeaway": "Fluid with the ball and rigid without it is one coherent model, not a contradiction. The switch between them is what you rehearse.", + "age_hint": "U15+", + "block": "mid", + "pass_risk_json": null, + "shape_render": "details_only", + "signature_animation_spec_json": null, + "static_shape_json": null, + "source_ref": "doc06:2.5", + "content_version": "1.1.0" + }, + { + "code": "ref_amorim_343_invert", + "name": "Amorim's 3-4-3, inverting the middle centre back", + "tag_line": "The central defender of the three steps in front of the other two to give the keeper a bounce option that does not exist wide.", + "formation_code": "343", + "core_idea": "Formation: 3-4-3 becoming 2-3-5 with the ball. The central defender of the back three steps in front of the other two, which gives the keeper a forward-facing bounce option and pulls their first line out of shape. Without the ball the shape becomes a 5-4-1, or a 4-4-2 when the press goes higher. Rotations: the middle centre back of a three inverts. Risk: it gives up the spare central defender against a lone striker playing on the shoulder of the last man. Provenance: Ruben Amorim's Sporting, 2020 to 2024.", + "signature_pattern_codes": ["B5", "B8", "A4"], + "keystone_roles_json": [ + {"role": "ball_playing_cb", "note": "the middle defender who steps in front"}, + {"role": "wide_cb", "note": "the two who widen as he leaves"}, + {"role": "wingback", "note": "the width, unchanged by the rotation"}, + {"role": "single_pivot", "note": "the screen the inverting defender joins"} + ], + "youth_takeaway": "The player who inverts does not have to be a fullback. Ask who on your team can receive facing forward, then move him there.", + "age_hint": "U15+", + "block": "mid", + "pass_risk_json": null, + "shape_render": "details_only", + "signature_animation_spec_json": null, + "static_shape_json": null, + "source_ref": "doc06:2.5", + "content_version": "1.1.0" + }, + { + "code": "ref_como_split_pivot", + "name": "Como under Fabregas, 4-2-3-1 with a splitting pivot", + "tag_line": "The double pivot splits to create a back-three illusion while inverted wingers take the half-spaces and the fullbacks own the touchline.", + "formation_code": "4231", + "core_idea": "Formation: 4-2-3-1 becoming 2-3-5 with the ball. The double pivot splits to create the illusion of a back three, the inverted wingers occupy the half-spaces, and the fullbacks own the touchline. The defensive line is held high, around forty two metres, so the playing surface stays short. Rotations: the splitting double pivot, plus the winger and fullback swapping the half-space and the touchline. Risk: a high line and a split pivot at the same time means one ball behind the line is a race against the keeper, so this shape needs a keeper who defends the space behind it. Provenance: Cesc Fabregas's Como, from 2024.", + "signature_pattern_codes": ["B5", "A5", "F7"], + "keystone_roles_json": [ + {"role": "single_pivot", "note": "the pivot pair that splits rather than sits"}, + {"role": "inside_forward", "note": "wingers inside, in the half-spaces"}, + {"role": "overlapping_fb", "note": "fullbacks own the touchline instead"}, + {"role": "sweeper_keeper", "note": "the keeper who defends the space behind a high line"} + ], + "youth_takeaway": "Possession is a way of controlling space, not a statistic. Ask what the ball is buying before you ask how much of it you had.", + "age_hint": "U16+", + "block": "high", + "pass_risk_json": null, + "shape_render": "details_only", + "signature_animation_spec_json": null, + "static_shape_json": null, + "source_ref": "doc06:2.5", + "content_version": "1.1.0" + }, + { + "code": "ref_low_block_counter", + "name": "The low-block counter, 5-4-1 into 3-4-3", + "tag_line": "The block exists to make the pitch small, and the outlet striker is the most important defender on the team.", + "formation_code": "541", + "core_idea": "Formation: 5-4-1 without the ball, 3-4-3 on the break. The block exists to make the pitch small, and the striker who holds the first pass out is the most important defender on the team, because every second he keeps the ball is a second the block gets to re-form. Rotations: none by design. This shape changes job through distance covered rather than through players swapping positions. Risk: without a functioning outlet the block faces ninety minutes of siege, and second balls around the box become constant emergencies. Provenance: the reference low block, drawn from Atletico Madrid under Simeone and from Leicester in 2015-16.", + "signature_pattern_codes": ["C1", "C2", "F8"], + "keystone_roles_json": [ + {"role": "covering_cb", "note": "the middle of the back five reads everything"}, + {"role": "wingback", "note": "both wing backs launch on the turnover"}, + {"role": "target_man", "note": "the outlet who holds the first pass"}, + {"role": "box_to_box_8", "note": "the four in midfield who cover the distance"} + ], + "youth_takeaway": "Every superiority the rest of this library teaches is one this shape is deliberately conceding. Knowing what you gave up is the lesson.", + "age_hint": "U13+", + "block": "low", + "pass_risk_json": null, + "shape_render": "details_only", + "signature_animation_spec_json": null, + "static_shape_json": null, + "source_ref": "doc06:2.5", + "content_version": "1.1.0" + }, + { + "code": "ref_barcelona_high_line", + "name": "Barcelona's high line as rest defence", + "tag_line": "Instead of leaving bodies behind the ball, leave none and hold an extreme offside line with the keeper sweeping behind it.", + "formation_code": "433", + "core_idea": "Formation: 4-3-3, with rest defence expressed as a line rather than as a group of players left behind the ball. The team holds an extreme offside line and asks the keeper to cover the whole space behind it. Rotations: the keeper as the spare man, which is the same idea applied to the build. Risk: the failure mode is specific and nameable. One mistimed step by one defender, or one call that does not go your way, and there is nothing at all between the runner and the keeper. Provenance: Hansi Flick's Barcelona, from 2024.", + "signature_pattern_codes": ["D1", "B5", "A5"], + "keystone_roles_json": [ + {"role": "sweeper_keeper", "note": "the keeper is the entire cover"}, + {"role": "ball_playing_cb", "note": "a back line that steps as one"}, + {"role": "single_pivot", "note": "the screen in front of the line"}, + {"role": "false_9", "note": "the front line that keeps the ball far from it"} + ], + "youth_takeaway": "Rest defence is a philosophy with more than one answer. This one asks eleven players to step together, which is a training problem rather than a tactics problem.", + "age_hint": "U16+", + "block": "high", + "pass_risk_json": null, + "shape_render": "details_only", + "signature_animation_spec_json": null, + "static_shape_json": null, + "source_ref": "doc06:2.5", + "content_version": "1.1.0" + } + ] +} diff --git a/seeds/position_archetypes.json b/seeds/position_archetypes.json new file mode 100644 index 0000000..030ce33 --- /dev/null +++ b/seeds/position_archetypes.json @@ -0,0 +1,721 @@ +{ + "content_version": "1.1.0", + "table": "position_archetypes", + "note": "doc 06 section 2.6. An archetype is finer than a role: a role says how a player plays the position, an archetype says which job he does inside a unit and what the unit then needs around him. Attached 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. duties_json is a closed seven-word vocabulary and is what the unit_balance_rules checker runs on. Duty definitions used consistently across every row below: tempo (decides how fast the ball circulates), progression (moves the ball past an opponent line, by pass or by carry, including beating a man), rest_defence (holds the space that protects the counter), width (occupies the touchline and holds the opposing fullback out), pin (occupies the last defensive line and holds the centre backs deep), box_threat (arrives in the opposition penalty area to finish), press_trigger (starts the press or steps out of the line to meet the ball). width and pin are deliberately separated: one stretches the pitch sideways, the other stretches it in depth.", + "items": [ + { + "code": "gk_sweeper", + "slot_family": "gk", + "name": "Sweeper Keeper", + "definition": "Defends the space between himself and a high defensive line, and acts as the spare man when the press comes.", + "key_attribute_keys": ["pace", "passing_range", "positional_discipline"], + "foot_hint": "either", + "awr_default": "med", + "dwr_default": "high", + "duties_json": ["rest_defence", "progression"], + "enables_pattern_codes": ["B5"], + "enables_rotation_codes": [], + "needs_around_it": "A back line willing to defend high, because a sweeper keeper behind a deep block has nothing to sweep.", + "exemplar_note": "Manuel Neuer, Marc-Andre ter Stegen. Not a licence: names are editorial reference points only.", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "gk_ball_player", + "slot_family": "gk", + "name": "Distributing Keeper", + "definition": "Starts the possession as an extra passer and breaks the first line with his feet, without necessarily defending high space.", + "key_attribute_keys": ["passing_range", "positional_discipline"], + "foot_hint": "either", + "awr_default": "low", + "dwr_default": "med", + "duties_json": ["tempo", "progression"], + "enables_pattern_codes": ["B5", "B9"], + "enables_rotation_codes": [], + "needs_around_it": "Centre backs who split wide and a six who shows for the return, or he has nobody to pass to.", + "exemplar_note": "Ederson, Alisson. Not a licence: names are editorial reference points only.", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "gk_line_keeper", + "slot_family": "gk", + "name": "Line Keeper", + "definition": "Holds his line, commands his box on crosses, and distributes long rather than short.", + "key_attribute_keys": ["aerial_physical", "positional_discipline"], + "foot_hint": "either", + "awr_default": "low", + "dwr_default": "med", + "duties_json": ["rest_defence"], + "enables_pattern_codes": ["C2"], + "enables_rotation_codes": [], + "needs_around_it": "A target man or a genuine second-ball unit, because his first option is length, not a short pass.", + "exemplar_note": "Petr Cech, Gianluigi Buffon. Not a licence: names are editorial reference points only.", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "cb_stopper", + "slot_family": "cb_central", + "name": "Stopper", + "definition": "Front-foot centre back who steps out of the line to kill the attack at its source and wins his duel early.", + "key_attribute_keys": ["aerial_physical", "pressing_engine", "pace"], + "foot_hint": "either", + "awr_default": "low", + "dwr_default": "high", + "duties_json": ["press_trigger"], + "enables_pattern_codes": ["D1"], + "enables_rotation_codes": [], + "needs_around_it": "A covering partner who holds the line behind him, or the space he vacates is the space they run into.", + "exemplar_note": "Sergio Ramos, Cristian Romero. Not a licence: names are editorial reference points only.", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "cb_coverer", + "slot_family": "cb_central", + "name": "Coverer", + "definition": "Reads the pass before it is played, defends the space behind his partner, and wins the footrace to balls in behind.", + "key_attribute_keys": ["pace", "positional_discipline"], + "foot_hint": "either", + "awr_default": "low", + "dwr_default": "high", + "duties_json": ["rest_defence"], + "enables_pattern_codes": [], + "enables_rotation_codes": [], + "needs_around_it": "A partner who steps, because two coverers means nobody ever meets the ball in front of the line.", + "exemplar_note": "Raphael Varane, Ruben Dias. Not a licence: names are editorial reference points only.", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "cb_ball_player", + "slot_family": "cb_central", + "name": "Ball-Playing Centre Back", + "definition": "Breaks the first line from a standing start, either with the vertical pass into midfield or the diagonal that switches the attack.", + "key_attribute_keys": ["passing_range", "carrying_1v1", "positional_discipline"], + "foot_hint": "same_side", + "awr_default": "med", + "dwr_default": "high", + "duties_json": ["progression", "rest_defence"], + "enables_pattern_codes": ["B5", "B3"], + "enables_rotation_codes": [], + "needs_around_it": "A six who moves to open the passing lane, otherwise the pass he is picked for never has a target.", + "exemplar_note": "Gerard Pique, Virgil van Dijk. Not a licence: names are editorial reference points only.", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "cb_stepping_pivot", + "slot_family": "cb_central", + "name": "Stepping Pivot", + "definition": "Steps out of the back line into midfield in possession to make the pivot a two, then drops back as the ball turns over.", + "key_attribute_keys": ["passing_range", "positional_discipline", "carrying_1v1"], + "foot_hint": "same_side", + "awr_default": "med", + "dwr_default": "high", + "duties_json": ["tempo", "progression"], + "enables_pattern_codes": ["B5", "B9"], + "enables_rotation_codes": [], + "needs_around_it": "A partner and a fullback who cover the space he leaves, and a coach who accepts a back three in possession.", + "exemplar_note": "John Stones (Manchester City, as the centre back who becomes a midfielder). Not a licence: names are editorial reference points only.", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "cb_wide_stepper", + "slot_family": "cb_wide", + "name": "Stepping Wide Centre Back", + "definition": "The outside centre back in a back three who follows his man out of the line into the channel and into midfield.", + "key_attribute_keys": ["pace", "pressing_engine", "aerial_physical"], + "foot_hint": "same_side", + "awr_default": "med", + "dwr_default": "high", + "duties_json": ["press_trigger", "rest_defence"], + "enables_pattern_codes": ["D1"], + "enables_rotation_codes": [], + "needs_around_it": "A central centre back who slides across behind him, or the channel he leaves is the pass they are looking for.", + "exemplar_note": "Antonio Ruediger (in a back three), Kim Min-jae. Not a licence: names are editorial reference points only.", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "cb_wide_coverer", + "slot_family": "cb_wide", + "name": "Channel Defender", + "definition": "The outside centre back who holds the line, defends the channel between himself and the wingback, and rarely leaves the three.", + "key_attribute_keys": ["pace", "positional_discipline"], + "foot_hint": "same_side", + "awr_default": "low", + "dwr_default": "high", + "duties_json": ["rest_defence"], + "enables_pattern_codes": [], + "enables_rotation_codes": [], + "needs_around_it": "A wingback who tracks back, because he will not follow the runner past halfway himself.", + "exemplar_note": "Cesar Azpilicueta (as the right of a back three). Not a licence: names are editorial reference points only.", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "cb_wide_carrier", + "slot_family": "cb_wide", + "name": "Carrying Wide Centre Back", + "definition": "Carries the ball out of the back three into the space a retreating winger leaves, and passes forward from midfield height.", + "key_attribute_keys": ["carrying_1v1", "passing_range", "pace"], + "foot_hint": "same_side", + "awr_default": "med", + "dwr_default": "med", + "duties_json": ["progression", "rest_defence"], + "enables_pattern_codes": ["B5", "B3"], + "enables_rotation_codes": [], + "needs_around_it": "A wingback high enough to pin the winger, so the ten metres in front of him are actually empty.", + "exemplar_note": "Alessandro Bastoni, Aymeric Laporte. Not a licence: names are editorial reference points only.", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "cb_wide_overlapper", + "slot_family": "cb_wide", + "name": "Overlapping Centre Back", + "definition": "Goes beyond the wingback on the outside, turning the back three into a two and making an extra man in the wide third.", + "key_attribute_keys": ["pace", "carrying_1v1"], + "foot_hint": "same_side", + "awr_default": "high", + "dwr_default": "med", + "duties_json": ["width", "progression"], + "enables_pattern_codes": ["A1", "F1"], + "enables_rotation_codes": ["R13"], + "needs_around_it": "A six who drops into the back line as he leaves, or the overlap costs the rest defence a body.", + "exemplar_note": "Chris Basham and Jack O'Connell (Sheffield United's overlapping centre backs). Not a licence: names are editorial reference points only.", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "fb_overlapper", + "slot_family": "fb", + "name": "Overlapping Fullback", + "definition": "Runs outside the winger to the byline and delivers, so the width in the final third comes from the back line.", + "key_attribute_keys": ["pace", "carrying_1v1", "pressing_engine"], + "foot_hint": "same_side", + "awr_default": "high", + "dwr_default": "high", + "duties_json": ["width", "progression"], + "enables_pattern_codes": ["A1", "F1", "B3"], + "enables_rotation_codes": [], + "needs_around_it": "A winger who comes inside and takes his marker with him, plus a midfielder who covers the flank on the turnover.", + "exemplar_note": "Dani Alves, Andrew Robertson. Not a licence: names are editorial reference points only.", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "fb_inverter", + "slot_family": "fb", + "name": "Inverted Fullback", + "definition": "Steps inside into the pivot in possession to overload the middle and protect the counter before the ball is lost.", + "key_attribute_keys": ["passing_range", "positional_discipline"], + "foot_hint": "either", + "awr_default": "med", + "dwr_default": "high", + "duties_json": ["tempo", "rest_defence"], + "enables_pattern_codes": ["B5", "B3"], + "enables_rotation_codes": [], + "needs_around_it": "A winger or a wide-rotating eight who holds the touchline, because he takes the width away when he steps in.", + "exemplar_note": "Philipp Lahm (under Guardiola), Joao Cancelo. Not a licence: names are editorial reference points only.", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "fb_underlapper", + "slot_family": "fb", + "name": "Underlapping Fullback", + "definition": "Comes inside and forward into the half-space beyond the midfield line, rather than dropping into the pivot behind it.", + "key_attribute_keys": ["carrying_1v1", "pace", "passing_range"], + "foot_hint": "opposite_side", + "awr_default": "high", + "dwr_default": "med", + "duties_json": ["progression", "box_threat"], + "enables_pattern_codes": ["A2", "F7"], + "enables_rotation_codes": [], + "needs_around_it": "A winger holding the touchline and a six who screens, because his run leaves the flank behind him empty.", + "exemplar_note": "Trent Alexander-Arnold (in the high inside role), Alejandro Grimaldo. Not a licence: names are editorial reference points only.", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "fb_defender", + "slot_family": "fb", + "name": "Defensive Fullback", + "definition": "Defends his one-versus-one first, stays in the back four, and offers the short sideways pass rather than the overlap.", + "key_attribute_keys": ["positional_discipline", "pace", "aerial_physical"], + "foot_hint": "same_side", + "awr_default": "low", + "dwr_default": "high", + "duties_json": ["rest_defence"], + "enables_pattern_codes": [], + "enables_rotation_codes": [], + "needs_around_it": "A winger who can hold the width alone, because no width will ever arrive from behind him.", + "exemplar_note": "Gary Neville, Nathaniel Clyne. Not a licence: names are editorial reference points only.", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "wb_flyer", + "slot_family": "wb", + "name": "Attacking Wingback", + "definition": "Runs the whole touchline, supplies the width on his side by himself, and reaches the byline in every settled attack.", + "key_attribute_keys": ["pace", "pressing_engine", "carrying_1v1"], + "foot_hint": "same_side", + "awr_default": "high", + "dwr_default": "high", + "duties_json": ["width", "progression"], + "enables_pattern_codes": ["A1", "F2", "B3"], + "enables_rotation_codes": [], + "needs_around_it": "A back three that stays as a three, and a wide forward who plays inside him rather than on top of him.", + "exemplar_note": "Achraf Hakimi, Marcos Alonso (as a left wingback). Not a licence: names are editorial reference points only.", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "wb_defensive", + "slot_family": "wb", + "name": "Defensive Wingback", + "definition": "Plays as the fifth defender first and joins the attack only once the ball is settled on his side of the pitch.", + "key_attribute_keys": ["positional_discipline", "aerial_physical", "pace"], + "foot_hint": "same_side", + "awr_default": "low", + "dwr_default": "high", + "duties_json": ["rest_defence"], + "enables_pattern_codes": [], + "enables_rotation_codes": [], + "needs_around_it": "A wide forward or an eight who takes the high touchline, since he arrives late or not at all.", + "exemplar_note": "Victor Moses (Chelsea 2016/17). Not a licence: names are editorial reference points only.", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "wb_inverted", + "slot_family": "wb", + "name": "Inverted Wingback", + "definition": "Tucks into midfield in possession to build a box in the middle, leaving the touchline to the wide forward ahead of him.", + "key_attribute_keys": ["passing_range", "positional_discipline", "carrying_1v1"], + "foot_hint": "either", + "awr_default": "med", + "dwr_default": "high", + "duties_json": ["tempo", "progression"], + "enables_pattern_codes": ["B5", "B3"], + "enables_rotation_codes": [], + "needs_around_it": "A wide forward who genuinely stays on the touchline, or the shape has a box in the middle and nothing outside it.", + "exemplar_note": null, + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "six_metronome", + "slot_family": "six", + "name": "Metronome", + "definition": "Sits in front of the back line, is always available for the return pass, and sets the speed the whole team plays at.", + "key_attribute_keys": ["passing_range", "positional_discipline"], + "foot_hint": "either", + "awr_default": "low", + "dwr_default": "high", + "duties_json": ["tempo", "rest_defence"], + "enables_pattern_codes": ["B5", "B3", "B8"], + "enables_rotation_codes": [], + "needs_around_it": "At least one eight who breaks forward, because he will circulate all afternoon and never break a line himself.", + "exemplar_note": "Sergio Busquets, Jorginho. Not a licence: names are editorial reference points only.", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "six_line_breaker", + "slot_family": "six", + "name": "Deep Line Breaker", + "definition": "Plays from the metronome's position but looks first for the pass that goes past a line rather than around it.", + "key_attribute_keys": ["passing_range", "positional_discipline"], + "foot_hint": "either", + "awr_default": "med", + "dwr_default": "med", + "duties_json": ["tempo", "progression"], + "enables_pattern_codes": ["B5", "F7", "B3"], + "enables_rotation_codes": [], + "needs_around_it": "A ball winner or a destroyer near him, because his eyes are upfield and his first thought is not the counter.", + "exemplar_note": "Andrea Pirlo, Toni Kroos. Not a licence: names are editorial reference points only.", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "six_destroyer", + "slot_family": "six", + "name": "Destroyer", + "definition": "Screens the back line, hunts the ball in the middle third, and ends their attack before it reaches the last line.", + "key_attribute_keys": ["pressing_engine", "positional_discipline", "aerial_physical"], + "foot_hint": "either", + "awr_default": "low", + "dwr_default": "high", + "duties_json": ["rest_defence", "press_trigger"], + "enables_pattern_codes": ["D1", "C1"], + "enables_rotation_codes": [], + "needs_around_it": "Someone alongside who can pass, because he wins the ball and then needs an outlet within two seconds.", + "exemplar_note": "Claude Makelele, N'Golo Kante. Not a licence: names are editorial reference points only.", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "six_carrier", + "slot_family": "six", + "name": "Carrying Six", + "definition": "Beats the first press by driving out of the pivot with the ball instead of passing around it.", + "key_attribute_keys": ["carrying_1v1", "pace", "positional_discipline"], + "foot_hint": "either", + "awr_default": "med", + "dwr_default": "high", + "duties_json": ["progression", "rest_defence"], + "enables_pattern_codes": ["B5", "C1"], + "enables_rotation_codes": [], + "needs_around_it": "Two forward options ahead of the carry, or he drives into traffic and loses it in the worst area of the pitch.", + "exemplar_note": "Declan Rice, Fernandinho. Not a licence: names are editorial reference points only.", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "six_shuttler", + "slot_family": "six", + "name": "Shuttler", + "definition": "The forward-going half of a double pivot: covers ground box to box and arrives in the opposition area from deep.", + "key_attribute_keys": ["pressing_engine", "carrying_1v1", "pace"], + "foot_hint": "either", + "awr_default": "high", + "dwr_default": "high", + "duties_json": ["progression", "box_threat"], + "enables_pattern_codes": ["A5", "A3"], + "enables_rotation_codes": [], + "needs_around_it": "A controller beside him who stays, because the pivot becomes a one every time he goes.", + "exemplar_note": "Patrick Vieira, Steven Gerrard. Not a licence: names are editorial reference points only.", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "eight_half_space_creator", + "slot_family": "eight", + "name": "Half-Space Creator", + "definition": "Receives between their midfield and back line in the half-space and plays the pass that beats the last line.", + "key_attribute_keys": ["passing_range", "positional_discipline"], + "foot_hint": "either", + "awr_default": "high", + "dwr_default": "med", + "duties_json": ["progression"], + "enables_pattern_codes": ["A5", "A2", "F7"], + "enables_rotation_codes": [], + "needs_around_it": "A six who holds, and a winger who pins the fullback so the half-space stays open.", + "exemplar_note": "David Silva, Kevin De Bruyne. Not a licence: names are editorial reference points only.", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "eight_box_crasher", + "slot_family": "eight", + "name": "Box Crasher", + "definition": "Arrives late and unmarked at the far post or the penalty spot, the third-man finisher of the midfield.", + "key_attribute_keys": ["pace", "aerial_physical"], + "foot_hint": "either", + "awr_default": "high", + "dwr_default": "med", + "duties_json": ["box_threat"], + "enables_pattern_codes": ["A5", "F1", "F4"], + "enables_rotation_codes": [], + "needs_around_it": "Someone else holding the middle, because he will not be there when the ball turns over.", + "exemplar_note": "Frank Lampard, Arturo Vidal. Not a licence: names are editorial reference points only.", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "eight_carrier", + "slot_family": "eight", + "name": "Line-Breaking Carrier", + "definition": "Breaks the line by driving through it rather than passing through it.", + "key_attribute_keys": ["carrying_1v1", "pace"], + "foot_hint": "either", + "awr_default": "high", + "dwr_default": "med", + "duties_json": ["progression"], + "enables_pattern_codes": ["C1", "A3"], + "enables_rotation_codes": [], + "needs_around_it": "Space to run into, so pair him with players who pin the last line rather than drop off it.", + "exemplar_note": "Paul Pogba, Yaya Toure. Not a licence: names are editorial reference points only.", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "eight_ball_winner", + "slot_family": "eight", + "name": "Ball Winner", + "definition": "The counterpress trigger of the midfield: wins the ball back in the five seconds after we lose it.", + "key_attribute_keys": ["pressing_engine", "positional_discipline"], + "foot_hint": "either", + "awr_default": "med", + "dwr_default": "high", + "duties_json": ["rest_defence", "press_trigger"], + "enables_pattern_codes": ["D1", "C1"], + "enables_rotation_codes": [], + "needs_around_it": "A creator alongside, or the trio has no forward pass.", + "exemplar_note": "Gennaro Gattuso, Javier Mascherano. Not a licence: names are editorial reference points only.", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "eight_deep_rotator", + "slot_family": "eight", + "name": "Deep Rotator", + "definition": "Drops beside the six to make a temporary double pivot, then leaves once the line is broken.", + "key_attribute_keys": ["passing_range", "positional_discipline"], + "foot_hint": "either", + "awr_default": "med", + "dwr_default": "high", + "duties_json": ["tempo", "rest_defence"], + "enables_pattern_codes": ["B5", "B3"], + "enables_rotation_codes": [], + "needs_around_it": "A partner who does the opposite, otherwise both drop and nobody occupies the space between the lines.", + "exemplar_note": "Thiago Alcantara, Ilkay Gundogan. Not a licence: names are editorial reference points only.", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "eight_wide_rotator", + "slot_family": "eight", + "name": "Wide Rotator", + "definition": "Takes the touchline when the fullback inverts, so the width never disappears.", + "key_attribute_keys": ["pace", "carrying_1v1"], + "foot_hint": "same_side", + "awr_default": "high", + "dwr_default": "med", + "duties_json": ["width", "progression"], + "enables_pattern_codes": ["A1", "B3", "F1"], + "enables_rotation_codes": [], + "needs_around_it": "An inverting fullback, because without one this archetype is just a bad winger.", + "exemplar_note": "Bernardo Silva (Manchester City, on the side Cancelo inverted from). Not a licence: names are editorial reference points only.", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "ten_between_the_lines", + "slot_family": "ten", + "name": "Between the Lines Ten", + "definition": "Receives on the half-turn between their midfield and back line, then plays the pass that unlocks the last line.", + "key_attribute_keys": ["passing_range", "carrying_1v1"], + "foot_hint": "either", + "awr_default": "high", + "dwr_default": "low", + "duties_json": ["progression"], + "enables_pattern_codes": ["A3", "A5", "F7"], + "enables_rotation_codes": [], + "needs_around_it": "A nine who holds the last line, because the space he wants only exists while the centre backs are pinned.", + "exemplar_note": "Mesut Ozil, Juan Roman Riquelme. Not a licence: names are editorial reference points only.", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "ten_shadow_striker", + "slot_family": "ten", + "name": "Shadow Striker", + "definition": "Plays off the nine and attacks the box himself, a second forward wearing a midfield number.", + "key_attribute_keys": ["pace", "carrying_1v1"], + "foot_hint": "either", + "awr_default": "high", + "dwr_default": "low", + "duties_json": ["box_threat"], + "enables_pattern_codes": ["A5", "F1"], + "enables_rotation_codes": [], + "needs_around_it": "A nine who occupies both centre backs, or he arrives in a box that is already full.", + "exemplar_note": "Dele Alli (Tottenham under Pochettino). Not a licence: names are editorial reference points only.", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "ten_pressing_ten", + "slot_family": "ten", + "name": "Pressing Ten", + "definition": "The first defender of a mid block: screens their pivot in the rest shape and springs the trap when the ball goes wide.", + "key_attribute_keys": ["pressing_engine", "positional_discipline"], + "foot_hint": "either", + "awr_default": "med", + "dwr_default": "high", + "duties_json": ["press_trigger"], + "enables_pattern_codes": ["D1"], + "enables_rotation_codes": [], + "needs_around_it": "Two forwards or wingers who press the same side with him, because the trigger only works if the trap closes.", + "exemplar_note": null, + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "wf_touchline_winger", + "slot_family": "wide_forward", + "name": "Touchline Winger", + "definition": "Stays on the chalk until the ball arrives, then attacks the fullback one versus one.", + "key_attribute_keys": ["pace", "carrying_1v1"], + "foot_hint": "same_side", + "awr_default": "high", + "dwr_default": "low", + "duties_json": ["width", "progression"], + "enables_pattern_codes": ["A1", "A2", "F1"], + "enables_rotation_codes": [], + "needs_around_it": "A fullback who stays inside or behind him, because two players on the same touchline is one player wasted.", + "exemplar_note": "Jeremy Doku, Antonio Valencia. Not a licence: names are editorial reference points only.", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "wf_inside_forward", + "slot_family": "wide_forward", + "name": "Inside Forward", + "definition": "Starts wide, comes inside onto his stronger foot, and finishes from the half-space.", + "key_attribute_keys": ["carrying_1v1", "pace"], + "foot_hint": "opposite_side", + "awr_default": "high", + "dwr_default": "low", + "duties_json": ["box_threat", "progression"], + "enables_pattern_codes": ["A1", "F4"], + "enables_rotation_codes": [], + "needs_around_it": "A fullback who overlaps outside him, or the flank he leaves is simply empty.", + "exemplar_note": "Arjen Robben, Mohamed Salah. Not a licence: names are editorial reference points only.", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "wf_channel_runner", + "slot_family": "wide_forward", + "name": "Channel Runner", + "definition": "Attacks the space between the centre back and the fullback the moment the ball can be played forward.", + "key_attribute_keys": ["pace", "positional_discipline"], + "foot_hint": "either", + "awr_default": "high", + "dwr_default": "low", + "duties_json": ["pin", "box_threat"], + "enables_pattern_codes": ["C1", "F8", "F7"], + "enables_rotation_codes": ["R1"], + "needs_around_it": "A midfielder who can pick the pass before the run dies, because timing without service is just running.", + "exemplar_note": "Kylian Mbappe, Thierry Henry (from the left). Not a licence: names are editorial reference points only.", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "wf_raumdeuter", + "slot_family": "wide_forward", + "name": "Raumdeuter", + "definition": "Holds no width and beats nobody, but arrives unmarked in the box because he read the space earlier than the defender.", + "key_attribute_keys": ["positional_discipline", "pace"], + "foot_hint": "either", + "awr_default": "high", + "dwr_default": "med", + "duties_json": ["box_threat"], + "enables_pattern_codes": ["A5", "F4"], + "enables_rotation_codes": [], + "needs_around_it": "A team-mate supplying width on his side, since he will not provide any of it himself.", + "exemplar_note": "Thomas Muller (the archetype takes its name from his own description of the job). Not a licence: names are editorial reference points only.", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "wf_pressing_winger", + "slot_family": "wide_forward", + "name": "Pressing Winger", + "definition": "Sets the press from the front with the curved run that locks the ball onto one side of the pitch.", + "key_attribute_keys": ["pressing_engine", "pace"], + "foot_hint": "same_side", + "awr_default": "high", + "dwr_default": "high", + "duties_json": ["press_trigger", "width"], + "enables_pattern_codes": ["D1", "C1"], + "enables_rotation_codes": [], + "needs_around_it": "A fullback and an eight who step up with him, because a solo press is only a warning to the opponent.", + "exemplar_note": "Sadio Mane (Liverpool under Klopp). Not a licence: names are editorial reference points only.", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "nine_target", + "slot_family": "nine", + "name": "Target Man", + "definition": "Holds the ball with his back to goal, wins the first contact, and brings the runners around him into the game.", + "key_attribute_keys": ["aerial_physical", "positional_discipline"], + "foot_hint": "either", + "awr_default": "med", + "dwr_default": "med", + "duties_json": ["pin", "box_threat"], + "enables_pattern_codes": ["A4", "A5", "C2"], + "enables_rotation_codes": ["R12"], + "needs_around_it": "Runners who go past him the instant he wins the header, or the second ball is theirs every time.", + "exemplar_note": "Didier Drogba, Olivier Giroud. Not a licence: names are editorial reference points only.", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "nine_runner", + "slot_family": "nine", + "name": "Runner in Behind", + "definition": "Plays on the shoulder and attacks the space behind the last line before the defence can drop into it.", + "key_attribute_keys": ["pace", "positional_discipline"], + "foot_hint": "either", + "awr_default": "high", + "dwr_default": "low", + "duties_json": ["pin", "box_threat"], + "enables_pattern_codes": ["F7", "F8", "C1"], + "enables_rotation_codes": ["R12"], + "needs_around_it": "A passer who looks up early, because the run is made once and does not come back.", + "exemplar_note": "Jamie Vardy, Erling Haaland. Not a licence: names are editorial reference points only.", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "nine_false", + "slot_family": "nine", + "name": "False Nine", + "definition": "Leaves the last line and drops into midfield, asking the centre backs whether they follow him or let him turn.", + "key_attribute_keys": ["passing_range", "carrying_1v1"], + "foot_hint": "either", + "awr_default": "high", + "dwr_default": "high", + "duties_json": ["progression"], + "enables_pattern_codes": ["A5", "A4"], + "enables_rotation_codes": ["R1"], + "needs_around_it": "Wide forwards who attack the space he vacates, or the back four keeps its shape and simply gains a spare man.", + "exemplar_note": "Lionel Messi (Barcelona under Guardiola), Cesc Fabregas (Spain 2012). Not a licence: names are editorial reference points only.", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "nine_poacher", + "slot_family": "nine", + "name": "Poacher", + "definition": "Lives on the last shoulder inside the width of the box and does almost all of his work in the final six yards.", + "key_attribute_keys": ["positional_discipline", "pace"], + "foot_hint": "either", + "awr_default": "med", + "dwr_default": "low", + "duties_json": ["pin", "box_threat"], + "enables_pattern_codes": ["F1", "F3"], + "enables_rotation_codes": [], + "needs_around_it": "A steady supply of cutbacks and low crosses, because he does not build the chance he finishes.", + "exemplar_note": "Filippo Inzaghi, Ruud van Nistelrooy. Not a licence: names are editorial reference points only.", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "nine_pressing_forward", + "slot_family": "nine", + "name": "Pressing Forward", + "definition": "Leads the press, sets the angle that shuts off the switch, and defends from the front for the full ninety minutes.", + "key_attribute_keys": ["pressing_engine", "pace"], + "foot_hint": "either", + "awr_default": "high", + "dwr_default": "high", + "duties_json": ["press_trigger", "pin"], + "enables_pattern_codes": ["D1", "C1"], + "enables_rotation_codes": [], + "needs_around_it": "A midfield that steps up with him, because a forward pressing alone only tells the opponent which way to play.", + "exemplar_note": "Roberto Firmino, Gabriel Jesus. Not a licence: names are editorial reference points only.", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + } + ] +} diff --git a/seeds/rondo_zones.json b/seeds/rondo_zones.json index 1bb9ece..fd46b22 100644 --- a/seeds/rondo_zones.json +++ b/seeds/rondo_zones.json @@ -1,79 +1,475 @@ { - "content_version": "1.0.0", + "content_version": "1.1.0", "table": "rondo_zones", - "note": "Bible 3G.2 describes the rondo map as a generic overlay on any 11v11 shape. Attached here to formation_code 433, the Bible's primary reference formation, per doc 03 section 5's formation_code natural key requirement.", + "note": "Bible 3G.2 gave five zones on the 4-3-3 only. doc 06 section 2.3 ships six zones on all six formations, with the polygons seeded per formation because a back three's first line is geometrically different from a back four's. The five original 4-3-3 rows keep their Bible source_ref, their rondo_name and their `teaches` copy; the one change to them is that `counterpress` is now doc 06's `counterpress_ring`. canonical_rondo is the label shown when NO opposition is placed. With opposition on the board the ratio is computed and this field is not read. It currently repeats the ratio already inside rondo_name, which is the shipped display name the formations screen renders; moving the ratio out of the name is a UI change rather than a seed change, so it is left for the ticket that renders canonical_rondo. counterpress_ring is a ball-relative circle of radius 18; its polygon bounds the half of the pitch the ring is coached in, and the circle is computed inside that.", "items": [ { "formation_code": "433", "zone_key": "first_line", "rondo_name": "4v2 / 3v2 (first-line build-up)", "teaches": "Two centre backs and the pivot, plus the keeper, against two pressing strikers: literally the 4v2 rondo, played at the edge of your own box.", - "polygon_json": [ - {"x": 0, "y": 10}, {"x": 35, "y": 10}, {"x": 35, "y": 90}, {"x": 0, "y": 90} - ], - "trains_pattern_codes": ["B5"], + "polygon_json": [{"x": 0, "y": 10}, {"x": 35, "y": 10}, {"x": 35, "y": 90}, {"x": 0, "y": 90}], + "trains_pattern_codes": ["B5", "B8"], + "canonical_rondo": "4v2 or 3v2", + "zone_kind": "polygon", + "radius": null, "source_ref": "bible:3G.2", - "content_version": "1.0.0" + "content_version": "1.1.0" }, { "formation_code": "433", "zone_key": "midfield_box", "rondo_name": "5v3 (the midfield box)", "teaches": "The double pivot, the ten, and a stepping centre back against the opposition midfield triangle: the same split-pass and pause logic as the 5v3 square.", - "polygon_json": [ - {"x": 35, "y": 15}, {"x": 65, "y": 15}, {"x": 65, "y": 85}, {"x": 35, "y": 85} - ], - "trains_pattern_codes": ["B8", "A5"], + "polygon_json": [{"x": 35, "y": 15}, {"x": 65, "y": 15}, {"x": 65, "y": 85}, {"x": 35, "y": 85}], + "trains_pattern_codes": ["A5", "B8"], + "canonical_rondo": "5v3", + "zone_kind": "polygon", + "radius": null, "source_ref": "bible:3G.2", - "content_version": "1.0.0" + "content_version": "1.1.0" }, { "formation_code": "433", "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} - ], + "polygon_json": [{"x": 20, "y": 0}, {"x": 90, "y": 0}, {"x": 90, "y": 25}, {"x": 20, "y": 25}], "trains_pattern_codes": ["A1", "A2", "F1"], + "canonical_rondo": "2v1 to 2v2", + "zone_kind": "polygon", + "radius": null, "source_ref": "bible:3G.2", - "content_version": "1.0.0" + "content_version": "1.1.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": [ - {"x": 20, "y": 75}, {"x": 90, "y": 75}, {"x": 90, "y": 100}, {"x": 20, "y": 100} - ], + "polygon_json": [{"x": 20, "y": 75}, {"x": 90, "y": 75}, {"x": 90, "y": 100}, {"x": 20, "y": 100}], "trains_pattern_codes": ["A1", "A2", "F1"], + "canonical_rondo": "2v1 to 2v2", + "zone_kind": "polygon", + "radius": null, "source_ref": "bible:3G.2", - "content_version": "1.0.0" + "content_version": "1.1.0" }, { "formation_code": "433", "zone_key": "last_line", "rondo_name": "2v2 (+1 keeper) (the last line)", "teaches": "Strikers against centre backs: the qualitative-superiority zone where pins, scissors, and runs in behind decide the game.", - "polygon_json": [ - {"x": 75, "y": 10}, {"x": 100, "y": 10}, {"x": 100, "y": 90}, {"x": 75, "y": 90} - ], + "polygon_json": [{"x": 75, "y": 10}, {"x": 100, "y": 10}, {"x": 100, "y": 90}, {"x": 75, "y": 90}], "trains_pattern_codes": ["R12"], + "canonical_rondo": "2v2 plus keeper", + "zone_kind": "polygon", + "radius": null, "source_ref": "bible:3G.2", - "content_version": "1.0.0" + "content_version": "1.1.0" }, { "formation_code": "433", - "zone_key": "counterpress", - "rondo_name": "4v4+3 (the counterpress moment)", - "teaches": "The five-second swarm is the neutral-player transition game played for real stakes, wherever the ball is lost.", - "polygon_json": [ - {"x": 30, "y": 10}, {"x": 70, "y": 10}, {"x": 70, "y": 90}, {"x": 30, "y": 90} - ], + "zone_key": "counterpress_ring", + "rondo_name": "4v4+3 (the counterpress ring)", + "teaches": "Lose the ball in their half and the swarm is the pivot, both eights and the nearest winger inside eighteen units of the ball. The ring is not a fixed zone: it is a circle of eighteen model units around the ball at the moment of loss, so rest defence is relative to the ball rather than to the pitch.", + "polygon_json": [{"x": 50, "y": 0}, {"x": 100, "y": 0}, {"x": 100, "y": 100}, {"x": 50, "y": 100}], "trains_pattern_codes": ["C1"], - "source_ref": "bible:3G.2", - "content_version": "1.0.0" + "canonical_rondo": "4v4 plus 3", + "zone_kind": "ball_relative_circle", + "radius": 18, + "source_ref": "doc06:2.3", + "content_version": "1.1.0" + }, + { + "formation_code": "4231", + "zone_key": "first_line", + "rondo_name": "4v2 / 3v2 (first-line build-up)", + "teaches": "Two centre backs, the double pivot and the keeper against their front two, the same rondo with a spare builder already inside the shape.", + "polygon_json": [{"x": 0, "y": 10}, {"x": 34, "y": 10}, {"x": 34, "y": 90}, {"x": 0, "y": 90}], + "trains_pattern_codes": ["B5", "B8"], + "canonical_rondo": "4v2 or 3v2", + "zone_kind": "polygon", + "radius": null, + "source_ref": "doc06:2.3", + "content_version": "1.1.0" + }, + { + "formation_code": "4231", + "zone_key": "midfield_box", + "rondo_name": "5v3 (the midfield box)", + "teaches": "The double pivot and the ten against their midfield line, where the free man is almost always behind the player who pressed the ball.", + "polygon_json": [{"x": 32, "y": 18}, {"x": 64, "y": 18}, {"x": 64, "y": 82}, {"x": 32, "y": 82}], + "trains_pattern_codes": ["A5", "B8"], + "canonical_rondo": "5v3", + "zone_kind": "polygon", + "radius": null, + "source_ref": "doc06:2.3", + "content_version": "1.1.0" + }, + { + "formation_code": "4231", + "zone_key": "flank_corridor_left", + "rondo_name": "2v1 to 2v2 (the flank corridor)", + "teaches": "Winger, fullback and a stepping pivot against their fullback and wide midfielder: the corridor rondo with a third man already inside it.", + "polygon_json": [{"x": 20, "y": 0}, {"x": 90, "y": 0}, {"x": 90, "y": 25}, {"x": 20, "y": 25}], + "trains_pattern_codes": ["A1", "A2", "F1"], + "canonical_rondo": "2v1 to 2v2", + "zone_kind": "polygon", + "radius": null, + "source_ref": "doc06:2.3", + "content_version": "1.1.0" + }, + { + "formation_code": "4231", + "zone_key": "flank_corridor_right", + "rondo_name": "2v1 to 2v2 (the flank corridor)", + "teaches": "Winger, fullback and a stepping pivot against their fullback and wide midfielder: the corridor rondo with a third man already inside it.", + "polygon_json": [{"x": 20, "y": 75}, {"x": 90, "y": 75}, {"x": 90, "y": 100}, {"x": 20, "y": 100}], + "trains_pattern_codes": ["A1", "A2", "F1"], + "canonical_rondo": "2v1 to 2v2", + "zone_kind": "polygon", + "radius": null, + "source_ref": "doc06:2.3", + "content_version": "1.1.0" + }, + { + "formation_code": "4231", + "zone_key": "last_line", + "rondo_name": "2v2 (+1 keeper) (the last line)", + "teaches": "A lone nine against two centre backs with the ten and the wingers arriving late. The rondo works only if the arrivals are on time.", + "polygon_json": [{"x": 74, "y": 15}, {"x": 100, "y": 15}, {"x": 100, "y": 85}, {"x": 74, "y": 85}], + "trains_pattern_codes": ["R12"], + "canonical_rondo": "2v2 plus keeper", + "zone_kind": "polygon", + "radius": null, + "source_ref": "doc06:2.3", + "content_version": "1.1.0" + }, + { + "formation_code": "4231", + "zone_key": "counterpress_ring", + "rondo_name": "4v4+3 (the counterpress ring)", + "teaches": "The double pivot gives this ring two anchors, so the ten and the wingers can commit forward knowing the second line is already behind them. The ring is not a fixed zone: it is a circle of eighteen model units around the ball at the moment of loss, so rest defence is relative to the ball rather than to the pitch.", + "polygon_json": [{"x": 50, "y": 0}, {"x": 100, "y": 0}, {"x": 100, "y": 100}, {"x": 50, "y": 100}], + "trains_pattern_codes": ["C1"], + "canonical_rondo": "4v4 plus 3", + "zone_kind": "ball_relative_circle", + "radius": 18, + "source_ref": "doc06:2.3", + "content_version": "1.1.0" + }, + { + "formation_code": "442", + "zone_key": "first_line", + "rondo_name": "4v2 / 3v2 (first-line build-up)", + "teaches": "Two centre backs, two centre midfielders and the keeper. The rondo is the same; the nearest support is further away than in any other shape.", + "polygon_json": [{"x": 0, "y": 12}, {"x": 34, "y": 12}, {"x": 34, "y": 88}, {"x": 0, "y": 88}], + "trains_pattern_codes": ["B5", "B8"], + "canonical_rondo": "4v2 or 3v2", + "zone_kind": "polygon", + "radius": null, + "source_ref": "doc06:2.3", + "content_version": "1.1.0" + }, + { + "formation_code": "442", + "zone_key": "midfield_box", + "rondo_name": "5v3 (the midfield box)", + "teaches": "Two centre midfielders against three: the honest version, played at a disadvantage, where the answer is one touch or none at all.", + "polygon_json": [{"x": 34, "y": 20}, {"x": 62, "y": 20}, {"x": 62, "y": 80}, {"x": 34, "y": 80}], + "trains_pattern_codes": ["A5", "B8"], + "canonical_rondo": "5v3", + "zone_kind": "polygon", + "radius": null, + "source_ref": "doc06:2.3", + "content_version": "1.1.0" + }, + { + "formation_code": "442", + "zone_key": "flank_corridor_left", + "rondo_name": "2v1 to 2v2 (the flank corridor)", + "teaches": "Wide midfielder and fullback against their fullback: two against one until their winger tracks back, which is the whole wide game of this shape.", + "polygon_json": [{"x": 18, "y": 0}, {"x": 90, "y": 0}, {"x": 90, "y": 25}, {"x": 18, "y": 25}], + "trains_pattern_codes": ["A1", "A2", "F1"], + "canonical_rondo": "2v1 to 2v2", + "zone_kind": "polygon", + "radius": null, + "source_ref": "doc06:2.3", + "content_version": "1.1.0" + }, + { + "formation_code": "442", + "zone_key": "flank_corridor_right", + "rondo_name": "2v1 to 2v2 (the flank corridor)", + "teaches": "Wide midfielder and fullback against their fullback: two against one until their winger tracks back, which is the whole wide game of this shape.", + "polygon_json": [{"x": 18, "y": 75}, {"x": 90, "y": 75}, {"x": 90, "y": 100}, {"x": 18, "y": 100}], + "trains_pattern_codes": ["A1", "A2", "F1"], + "canonical_rondo": "2v1 to 2v2", + "zone_kind": "polygon", + "radius": null, + "source_ref": "doc06:2.3", + "content_version": "1.1.0" + }, + { + "formation_code": "442", + "zone_key": "last_line", + "rondo_name": "2v2 (+1 keeper) (the last line)", + "teaches": "Two strikers against two centre backs, the cleanest last-line rondo there is, and the reason this shape counters so well.", + "polygon_json": [{"x": 72, "y": 20}, {"x": 100, "y": 20}, {"x": 100, "y": 80}, {"x": 72, "y": 80}], + "trains_pattern_codes": ["R12"], + "canonical_rondo": "2v2 plus keeper", + "zone_kind": "polygon", + "radius": null, + "source_ref": "doc06:2.3", + "content_version": "1.1.0" + }, + { + "formation_code": "442", + "zone_key": "counterpress_ring", + "rondo_name": "4v4+3 (the counterpress ring)", + "teaches": "Two banks of four counterpress in unison or not at all, because a flat line has no second layer to catch what the first one misses. The ring is not a fixed zone: it is a circle of eighteen model units around the ball at the moment of loss, so rest defence is relative to the ball rather than to the pitch.", + "polygon_json": [{"x": 50, "y": 0}, {"x": 100, "y": 0}, {"x": 100, "y": 100}, {"x": 50, "y": 100}], + "trains_pattern_codes": ["C1"], + "canonical_rondo": "4v4 plus 3", + "zone_kind": "ball_relative_circle", + "radius": 18, + "source_ref": "doc06:2.3", + "content_version": "1.1.0" + }, + { + "formation_code": "352", + "zone_key": "first_line", + "rondo_name": "4v2 / 3v2 (first-line build-up)", + "teaches": "Three centre backs and the keeper against a front two, so the spare man exists before anybody moves. The question is who uses him.", + "polygon_json": [{"x": 0, "y": 18}, {"x": 30, "y": 18}, {"x": 30, "y": 82}, {"x": 0, "y": 82}], + "trains_pattern_codes": ["B5", "B8"], + "canonical_rondo": "4v2 or 3v2", + "zone_kind": "polygon", + "radius": null, + "source_ref": "doc06:2.3", + "content_version": "1.1.0" + }, + { + "formation_code": "352", + "zone_key": "midfield_box", + "rondo_name": "5v3 (the midfield box)", + "teaches": "Three central midfielders against their two or three, the argument this shape exists to make, played inside a thirty-unit square.", + "polygon_json": [{"x": 36, "y": 22}, {"x": 68, "y": 22}, {"x": 68, "y": 78}, {"x": 36, "y": 78}], + "trains_pattern_codes": ["A5", "B8"], + "canonical_rondo": "5v3", + "zone_kind": "polygon", + "radius": null, + "source_ref": "doc06:2.3", + "content_version": "1.1.0" + }, + { + "formation_code": "352", + "zone_key": "flank_corridor_left", + "rondo_name": "2v1 to 2v2 (the flank corridor)", + "teaches": "The wing back alone against their fullback until a striker or midfielder arrives, so this corridor rondo starts at one against one.", + "polygon_json": [{"x": 12, "y": 0}, {"x": 95, "y": 0}, {"x": 95, "y": 25}, {"x": 12, "y": 25}], + "trains_pattern_codes": ["A1", "A2", "F1"], + "canonical_rondo": "2v1 to 2v2", + "zone_kind": "polygon", + "radius": null, + "source_ref": "doc06:2.3", + "content_version": "1.1.0" + }, + { + "formation_code": "352", + "zone_key": "flank_corridor_right", + "rondo_name": "2v1 to 2v2 (the flank corridor)", + "teaches": "The wing back alone against their fullback until a striker or midfielder arrives, so this corridor rondo starts at one against one.", + "polygon_json": [{"x": 12, "y": 75}, {"x": 95, "y": 75}, {"x": 95, "y": 100}, {"x": 12, "y": 100}], + "trains_pattern_codes": ["A1", "A2", "F1"], + "canonical_rondo": "2v1 to 2v2", + "zone_kind": "polygon", + "radius": null, + "source_ref": "doc06:2.3", + "content_version": "1.1.0" + }, + { + "formation_code": "352", + "zone_key": "last_line", + "rondo_name": "2v2 (+1 keeper) (the last line)", + "teaches": "Two strikers against three defenders. They are outnumbered, so the pair have to move off each other rather than wait for service.", + "polygon_json": [{"x": 74, "y": 22}, {"x": 100, "y": 22}, {"x": 100, "y": 78}, {"x": 74, "y": 78}], + "trains_pattern_codes": ["R12"], + "canonical_rondo": "2v2 plus keeper", + "zone_kind": "polygon", + "radius": null, + "source_ref": "doc06:2.3", + "content_version": "1.1.0" + }, + { + "formation_code": "352", + "zone_key": "counterpress_ring", + "rondo_name": "4v4+3 (the counterpress ring)", + "teaches": "Three central midfielders make this the densest ring of any shape, provided the wing backs are not already beyond the ball. The ring is not a fixed zone: it is a circle of eighteen model units around the ball at the moment of loss, so rest defence is relative to the ball rather than to the pitch.", + "polygon_json": [{"x": 50, "y": 0}, {"x": 100, "y": 0}, {"x": 100, "y": 100}, {"x": 50, "y": 100}], + "trains_pattern_codes": ["C1"], + "canonical_rondo": "4v4 plus 3", + "zone_kind": "ball_relative_circle", + "radius": 18, + "source_ref": "doc06:2.3", + "content_version": "1.1.0" + }, + { + "formation_code": "343", + "zone_key": "first_line", + "rondo_name": "4v2 / 3v2 (first-line build-up)", + "teaches": "Three centre backs and a double pivot: the widest first line in the book, because the wide defenders start close to the touchlines.", + "polygon_json": [{"x": 0, "y": 16}, {"x": 30, "y": 16}, {"x": 30, "y": 84}, {"x": 0, "y": 84}], + "trains_pattern_codes": ["B5", "B8"], + "canonical_rondo": "4v2 or 3v2", + "zone_kind": "polygon", + "radius": null, + "source_ref": "doc06:2.3", + "content_version": "1.1.0" + }, + { + "formation_code": "343", + "zone_key": "midfield_box", + "rondo_name": "5v3 (the midfield box)", + "teaches": "Two central midfielders holding the middle against more of them, so this rondo is survived rather than won, and the exits are wide.", + "polygon_json": [{"x": 34, "y": 25}, {"x": 64, "y": 25}, {"x": 64, "y": 75}, {"x": 34, "y": 75}], + "trains_pattern_codes": ["A5", "B8"], + "canonical_rondo": "5v3", + "zone_kind": "polygon", + "radius": null, + "source_ref": "doc06:2.3", + "content_version": "1.1.0" + }, + { + "formation_code": "343", + "zone_key": "flank_corridor_left", + "rondo_name": "2v1 to 2v2 (the flank corridor)", + "teaches": "Wing back and wide forward against their fullback and winger, the pairing this shape is built to create again and again.", + "polygon_json": [{"x": 14, "y": 0}, {"x": 95, "y": 0}, {"x": 95, "y": 25}, {"x": 14, "y": 25}], + "trains_pattern_codes": ["A1", "A2", "F1"], + "canonical_rondo": "2v1 to 2v2", + "zone_kind": "polygon", + "radius": null, + "source_ref": "doc06:2.3", + "content_version": "1.1.0" + }, + { + "formation_code": "343", + "zone_key": "flank_corridor_right", + "rondo_name": "2v1 to 2v2 (the flank corridor)", + "teaches": "Wing back and wide forward against their fullback and winger, the pairing this shape is built to create again and again.", + "polygon_json": [{"x": 14, "y": 75}, {"x": 95, "y": 75}, {"x": 95, "y": 100}, {"x": 14, "y": 100}], + "trains_pattern_codes": ["A1", "A2", "F1"], + "canonical_rondo": "2v1 to 2v2", + "zone_kind": "polygon", + "radius": null, + "source_ref": "doc06:2.3", + "content_version": "1.1.0" + }, + { + "formation_code": "343", + "zone_key": "last_line", + "rondo_name": "2v2 (+1 keeper) (the last line)", + "teaches": "A front three against a back four, won by pinning both centre backs so the space beside them opens for the wide forwards.", + "polygon_json": [{"x": 74, "y": 10}, {"x": 100, "y": 10}, {"x": 100, "y": 90}, {"x": 74, "y": 90}], + "trains_pattern_codes": ["R12"], + "canonical_rondo": "2v2 plus keeper", + "zone_kind": "polygon", + "radius": null, + "source_ref": "doc06:2.3", + "content_version": "1.1.0" + }, + { + "formation_code": "343", + "zone_key": "counterpress_ring", + "rondo_name": "4v4+3 (the counterpress ring)", + "teaches": "Only two central midfielders live near the ball, so the wide forwards have to be part of the ring rather than watching it. The ring is not a fixed zone: it is a circle of eighteen model units around the ball at the moment of loss, so rest defence is relative to the ball rather than to the pitch.", + "polygon_json": [{"x": 50, "y": 0}, {"x": 100, "y": 0}, {"x": 100, "y": 100}, {"x": 50, "y": 100}], + "trains_pattern_codes": ["C1"], + "canonical_rondo": "4v4 plus 3", + "zone_kind": "ball_relative_circle", + "radius": 18, + "source_ref": "doc06:2.3", + "content_version": "1.1.0" + }, + { + "formation_code": "541", + "zone_key": "first_line", + "rondo_name": "4v2 / 3v2 (first-line build-up)", + "teaches": "Five defenders and a keeper against one presser. Numbers are never the problem here. Finding a forward pass before the block re-forms is.", + "polygon_json": [{"x": 0, "y": 6}, {"x": 26, "y": 6}, {"x": 26, "y": 94}, {"x": 0, "y": 94}], + "trains_pattern_codes": ["B5", "B8"], + "canonical_rondo": "4v2 or 3v2", + "zone_kind": "polygon", + "radius": null, + "source_ref": "doc06:2.3", + "content_version": "1.1.0" + }, + { + "formation_code": "541", + "zone_key": "midfield_box", + "rondo_name": "5v3 (the midfield box)", + "teaches": "Four across the middle inside a low block. The rondo here is the defensive one, and the coaching point is the distance between the four.", + "polygon_json": [{"x": 26, "y": 12}, {"x": 58, "y": 12}, {"x": 58, "y": 88}, {"x": 26, "y": 88}], + "trains_pattern_codes": ["A5", "B8"], + "canonical_rondo": "5v3", + "zone_kind": "polygon", + "radius": null, + "source_ref": "doc06:2.3", + "content_version": "1.1.0" + }, + { + "formation_code": "541", + "zone_key": "flank_corridor_left", + "rondo_name": "2v1 to 2v2 (the flank corridor)", + "teaches": "The wing back defending the corridor rather than playing in it, until a turnover flips the same space the other way.", + "polygon_json": [{"x": 8, "y": 0}, {"x": 92, "y": 0}, {"x": 92, "y": 25}, {"x": 8, "y": 25}], + "trains_pattern_codes": ["A1", "A2", "F1"], + "canonical_rondo": "2v1 to 2v2", + "zone_kind": "polygon", + "radius": null, + "source_ref": "doc06:2.3", + "content_version": "1.1.0" + }, + { + "formation_code": "541", + "zone_key": "flank_corridor_right", + "rondo_name": "2v1 to 2v2 (the flank corridor)", + "teaches": "The wing back defending the corridor rather than playing in it, until a turnover flips the same space the other way.", + "polygon_json": [{"x": 8, "y": 75}, {"x": 92, "y": 75}, {"x": 92, "y": 100}, {"x": 8, "y": 100}], + "trains_pattern_codes": ["A1", "A2", "F1"], + "canonical_rondo": "2v1 to 2v2", + "zone_kind": "polygon", + "radius": null, + "source_ref": "doc06:2.3", + "content_version": "1.1.0" + }, + { + "formation_code": "541", + "zone_key": "last_line", + "rondo_name": "2v2 (+1 keeper) (the last line)", + "teaches": "One striker against three or four defenders. He cannot win this rondo, and pretending otherwise is how a counter dies at its first pass.", + "polygon_json": [{"x": 70, "y": 30}, {"x": 100, "y": 30}, {"x": 100, "y": 70}, {"x": 70, "y": 70}], + "trains_pattern_codes": ["R12"], + "canonical_rondo": "2v2 plus keeper", + "zone_kind": "polygon", + "radius": null, + "source_ref": "doc06:2.3", + "content_version": "1.1.0" + }, + { + "formation_code": "541", + "zone_key": "counterpress_ring", + "rondo_name": "4v4+3 (the counterpress ring)", + "teaches": "A 5-4-1 rarely loses the ball in their half at all, so this ring is the rarest and the most valuable moment the shape gets. The ring is not a fixed zone: it is a circle of eighteen model units around the ball at the moment of loss, so rest defence is relative to the ball rather than to the pitch.", + "polygon_json": [{"x": 50, "y": 0}, {"x": 100, "y": 0}, {"x": 100, "y": 100}, {"x": 50, "y": 100}], + "trains_pattern_codes": ["C1"], + "canonical_rondo": "4v4 plus 3", + "zone_kind": "ball_relative_circle", + "radius": 18, + "source_ref": "doc06:2.3", + "content_version": "1.1.0" } ] } diff --git a/seeds/rotation_systems.json b/seeds/rotation_systems.json new file mode 100644 index 0000000..98e79a6 --- /dev/null +++ b/seeds/rotation_systems.json @@ -0,0 +1,895 @@ +{ + "content_version": "1.1.0", + "table": "rotation_systems", + "note": "doc 06 section 2.5. Structural rotations (who changes job), distinct from the movement patterns already seeded as library_items R1, R12 and R13. Every row states its cost in `risk`: a rotation library that lists only benefits is marketing, not coaching, so the validator refuses a row without one. Slot names in what_moves_json and animation_spec_json are the rotation's own generic names, not formation slot ids, because one rotation applies across several formations whose slot vocabularies differ.", + "items": [ + { + "code": "rot_invert_fb_pivot", + "name": "Inverted fullback into the pivot", + "family": "wide", + "applies_to_formations": ["433", "4231", "442"], + "produces_shape": "3-2-5", + "trigger": "A goal kick, or centre-back circulation against a two-striker press.", + "what_moves_json": [ + {"slot": "fullback", "from": {"x": 22, "y": 12}, "to": {"x": 46, "y": 40}, "becomes": "pivot"}, + { + "slot": "far_fullback", + "from": {"x": 22, "y": 88}, + "to": {"x": 30, "y": 74}, + "becomes": "third_cb" + } + ], + "coaching_points_json": [ + "The fullback steps inside while the ball is travelling, not once it has arrived, so the pivot line is already a two when the receiver looks up.", + "The winger ahead of him now holds that touchline alone, and has to stay high and wide to keep their fullback pinned.", + "The far fullback tucks in at the same moment: a back three that forms late is a back two for the first three seconds." + ], + "risk": "The flank behind him is empty on the turnover, so this needs a winger who defends or a wide centre back who can cover the channel.", + "requires_profile_json": { + "fullback": { + "archetypes": ["fb_inverter"], + "foot": null, + "attributes": ["positional_discipline", "passing_range"] + }, + "far_fullback": { + "archetypes": ["fb_defender", "fb_underlapper"], + "foot": null, + "attributes": ["positional_discipline"] + } + }, + "animation_spec_json": { + "slots": [ + {"slot": "cb", "role_hint": "CB", "start": {"x": 22, "y": 62}}, + {"slot": "six", "role_hint": "DM", "start": {"x": 42, "y": 50}}, + {"slot": "fullback", "role_hint": "FB", "start": {"x": 22, "y": 12}}, + {"slot": "far_fullback", "role_hint": "FB", "start": {"x": 22, "y": 88}}, + {"slot": "winger", "role_hint": "W", "start": {"x": 78, "y": 15}} + ], + "ball": {"holder_slot": "cb"}, + "steps": [ + { + "n": 1, + "caption": "The centre back carries out and the first line comes to meet him.", + "moves": [] + }, + { + "n": 2, + "caption": "The fullback steps inside beside the six while the far fullback tucks into the back line.", + "moves": [ + {"slot": "fullback", "to": {"x": 46, "y": 40}, "arc": "inside"}, + {"slot": "far_fullback", "to": {"x": 30, "y": 74}, "arc": "inside"} + ] + }, + { + "n": 3, + "caption": "The pivot is a two, so the free man receives on the half-turn.", + "moves": [{"slot": "winger", "to": {"x": 84, "y": 10}}], + "ball_to": {"bind_slot": "fullback", "trajectory": "ground"} + } + ], + "loop": true + }, + "exemplar_note": "Philipp Lahm under Guardiola at Bayern, Oleksandr Zinchenko at Arsenal. Not a licence: names are editorial reference points only.", + "source_ref": "doc06:2.5", + "content_version": "1.1.0" + }, + { + "code": "rot_invert_fb_high", + "name": "Fullback into the eight line", + "family": "wide", + "applies_to_formations": ["433", "4231"], + "produces_shape": "3-1-6", + "trigger": "Their block is already pinned deep and we need another body between the lines.", + "what_moves_json": [{"slot": "fullback", "from": {"x": 22, "y": 88}, "to": {"x": 66, "y": 74}, "becomes": "eight"}], + "coaching_points_json": [ + "He arrives at the height of the eights, not between them and the ball, so he is a receiver rather than another circulator.", + "The single screener has to sit centrally and hold: the moment he chases the ball there is nobody behind six players." + ], + "risk": "Only one screener sits behind six attacking players, so this is a shape for chasing a game, not a default setting.", + "requires_profile_json": { + "fullback": { + "archetypes": ["fb_inverter", "fb_underlapper"], + "foot": null, + "attributes": ["pressing_engine", "carrying_1v1"] + }, + "screener": { + "archetypes": ["six_destroyer", "six_metronome"], + "foot": null, + "attributes": ["positional_discipline"] + } + }, + "animation_spec_json": { + "slots": [ + {"slot": "fullback", "role_hint": "FB", "start": {"x": 22, "y": 88}}, + {"slot": "screener", "role_hint": "DM", "start": {"x": 44, "y": 50}}, + {"slot": "eight", "role_hint": "CM", "start": {"x": 62, "y": 68}}, + {"slot": "cb", "role_hint": "CB", "start": {"x": 26, "y": 60}} + ], + "ball": {"holder_slot": "cb"}, + "steps": [ + { + "n": 1, + "caption": "The block is already deep, so circulation in front of it achieves nothing.", + "moves": [] + }, + { + "n": 2, + "caption": "The fullback steps inside and forward to the height of the eights.", + "moves": [{"slot": "fullback", "to": {"x": 66, "y": 74}, "arc": "inside"}] + }, + { + "n": 3, + "caption": "Six players now occupy the last two lines, with one screener behind them.", + "moves": [{"slot": "eight", "to": {"x": 74, "y": 58}}], + "ball_to": {"bind_slot": "fullback", "trajectory": "ground"} + } + ], + "loop": true + }, + "exemplar_note": "Arsenal under Arteta with Zinchenko or Timber stepping high. Not a licence: names are editorial reference points only.", + "source_ref": "doc06:2.5", + "content_version": "1.1.0" + }, + { + "code": "rot_cb_step", + "name": "Centre back steps into midfield", + "family": "first_line", + "applies_to_formations": ["433", "4231", "442", "352", "343"], + "produces_shape": "3-2-5 or 2-3-5", + "trigger": "Their first line refuses to press, so the free man has to come from the back.", + "what_moves_json": [{"slot": "cb", "from": {"x": 22, "y": 62}, "to": {"x": 46, "y": 56}, "becomes": "pivot"}], + "coaching_points_json": [ + "He carries into the space rather than passing into it: the point is to make a defender decide, and a pass decides nothing.", + "The moment a midfielder comes to him, the pass is already gone into the space that midfielder left.", + "The remaining defenders slide across as he goes, so the line stays a line rather than becoming a gap." + ], + "risk": "If he is caught stepping, the back line is a two against their front two with nobody spare behind it.", + "requires_profile_json": { + "cb": { + "archetypes": ["cb_stepping_pivot", "cb_ball_player"], + "foot": null, + "attributes": ["passing_range", "carrying_1v1"] + } + }, + "animation_spec_json": { + "slots": [ + {"slot": "cb", "role_hint": "CB", "start": {"x": 22, "y": 62}}, + {"slot": "partner_cb", "role_hint": "CB", "start": {"x": 22, "y": 38}}, + {"slot": "six", "role_hint": "DM", "start": {"x": 42, "y": 44}}, + {"slot": "opp_striker", "role_hint": null, "start": {"x": 34, "y": 50}, "side": "opponent"} + ], + "ball": {"holder_slot": "cb"}, + "steps": [ + { + "n": 1, + "caption": "Their striker holds his position and refuses to press the ball.", + "moves": [] + }, + { + "n": 2, + "caption": "The centre back carries past him into the pivot line.", + "moves": [{"slot": "cb", "to": {"x": 46, "y": 56}}] + }, + { + "n": 3, + "caption": "Their midfield now has to come out, and the pass goes behind whoever does.", + "moves": [{"slot": "six", "to": {"x": 52, "y": 34}}], + "ball_to": {"bind_slot": "six", "trajectory": "ground"} + } + ], + "loop": true + }, + "exemplar_note": "John Stones for Manchester City, Josko Gvardiol carrying from the left. Not a licence: names are editorial reference points only.", + "source_ref": "doc06:2.5", + "content_version": "1.1.0" + }, + { + "code": "rot_cb_invert_middle", + "name": "Middle centre back of a three inverts", + "family": "first_line", + "applies_to_formations": ["352", "343", "541"], + "produces_shape": "2-3-5", + "trigger": "The keeper is under pressure and needs a bounce option that does not exist out wide.", + "what_moves_json": [{"slot": "middle_cb", "from": {"x": 20, "y": 50}, "to": {"x": 42, "y": 50}, "becomes": "pivot"}], + "coaching_points_json": [ + "He steps in front of the other two, not between them: the whole value is being a forward-facing option the keeper can see.", + "The two remaining defenders widen as he leaves, so the keeper still has two angles rather than one." + ], + "risk": "It gives up the spare central defender against a lone striker who plays on the shoulder of the last man.", + "requires_profile_json": { + "middle_cb": { + "archetypes": ["cb_stepping_pivot", "cb_ball_player"], + "foot": null, + "attributes": ["passing_range", "positional_discipline"] + } + }, + "animation_spec_json": { + "slots": [ + {"slot": "keeper", "role_hint": "GK", "start": {"x": 8, "y": 50}}, + {"slot": "middle_cb", "role_hint": "CB", "start": {"x": 20, "y": 50}}, + {"slot": "left_cb", "role_hint": "CB", "start": {"x": 22, "y": 28}}, + {"slot": "right_cb", "role_hint": "CB", "start": {"x": 22, "y": 72}} + ], + "ball": {"holder_slot": "keeper"}, + "steps": [ + {"n": 1, "caption": "The keeper has the ball and both wide options are covered.", "moves": []}, + { + "n": 2, + "caption": "The middle defender steps in front of the other two.", + "moves": [ + {"slot": "middle_cb", "to": {"x": 42, "y": 50}}, + {"slot": "left_cb", "to": {"x": 22, "y": 20}}, + {"slot": "right_cb", "to": {"x": 22, "y": 80}} + ] + }, + { + "n": 3, + "caption": "The bounce goes through the centre, where nobody expected an option.", + "moves": [], + "ball_to": {"bind_slot": "middle_cb", "trajectory": "ground"} + } + ], + "loop": true + }, + "exemplar_note": "Ruben Amorim's Sporting sides, with the central defender of the three stepping in. Not a licence: names are editorial reference points only.", + "source_ref": "doc06:2.5", + "content_version": "1.1.0" + }, + { + "code": "rot_pivot_drop", + "name": "Salida lavolpiana, the pivot drops in", + "family": "pivot", + "applies_to_formations": ["433", "442", "4231"], + "produces_shape": "3-2 build", + "trigger": "Two strikers are pressing our two centre backs and we need a third builder.", + "what_moves_json": [ + {"slot": "six", "from": {"x": 42, "y": 50}, "to": {"x": 18, "y": 50}, "becomes": "third_cb"}, + {"slot": "cb_l", "from": {"x": 20, "y": 35}, "to": {"x": 20, "y": 24}, "becomes": "wide_cb"}, + {"slot": "cb_r", "from": {"x": 20, "y": 65}, "to": {"x": 20, "y": 76}, "becomes": "wide_cb"} + ], + "coaching_points_json": [ + "The centre backs split as he drops, in the same movement: three players arriving at three places at once is what beats a front two.", + "He drops to receive facing forward. Dropping to stand with his back to play just moves the problem ten metres.", + "Somebody has to take the screening job he vacated, and the team should know who before the ball moves." + ], + "risk": "It removes the screen in front of the back line, and played badly the counter goes straight through the space he left.", + "requires_profile_json": { + "six": { + "archetypes": ["six_metronome", "six_line_breaker"], + "foot": null, + "attributes": ["passing_range", "positional_discipline"] + } + }, + "animation_spec_json": { + "slots": [ + {"slot": "six", "role_hint": "DM", "start": {"x": 42, "y": 50}}, + {"slot": "cb_l", "role_hint": "CB", "start": {"x": 20, "y": 35}}, + {"slot": "cb_r", "role_hint": "CB", "start": {"x": 20, "y": 65}}, + {"slot": "keeper", "role_hint": "GK", "start": {"x": 6, "y": 50}} + ], + "ball": {"holder_slot": "keeper"}, + "steps": [ + { + "n": 1, + "caption": "Two strikers press the pair, so there is no free man in the first line.", + "moves": [] + }, + { + "n": 2, + "caption": "The six drops between the centre backs as they split.", + "moves": [ + {"slot": "six", "to": {"x": 18, "y": 50}}, + {"slot": "cb_l", "to": {"x": 20, "y": 24}}, + {"slot": "cb_r", "to": {"x": 20, "y": 76}} + ] + }, + { + "n": 3, + "caption": "Three against two, and the ball goes to whichever of the three is free.", + "moves": [], + "ball_to": {"bind_slot": "cb_l", "trajectory": "ground"} + } + ], + "loop": true + }, + "exemplar_note": "Named for Ricardo La Volpe, later a Guardiola staple with Busquets. Not a licence: names are editorial reference points only.", + "source_ref": "doc06:2.5", + "content_version": "1.1.0" + }, + { + "code": "rot_double_pivot_split", + "name": "One of the double pivot drops, the other holds", + "family": "pivot", + "applies_to_formations": ["4231", "343", "442"], + "produces_shape": "3-2-5", + "trigger": "The press arrives in a 4-4-2 and our two centre backs are two against two.", + "what_moves_json": [ + {"slot": "pivot_a", "from": {"x": 38, "y": 40}, "to": {"x": 20, "y": 50}, "becomes": "third_cb"}, + { + "slot": "pivot_b", + "from": {"x": 38, "y": 60}, + "to": {"x": 42, "y": 52}, + "becomes": "single_screen" + } + ], + "coaching_points_json": [ + "Which pivot drops is decided by the ball, not by the team sheet: whichever one is on the far side of the press has the cleaner journey.", + "The partner slides central rather than staying wide, because he is now the only screen and has to cover the middle." + ], + "risk": "The remaining pivot is alone against their two eights, so every second ball in the centre starts as a one against two.", + "requires_profile_json": { + "pivot_a": { + "archetypes": ["six_metronome", "six_line_breaker"], + "foot": null, + "attributes": ["passing_range"] + }, + "pivot_b": { + "archetypes": ["six_destroyer", "six_shuttler"], + "foot": null, + "attributes": ["positional_discipline", "pressing_engine"] + } + }, + "animation_spec_json": { + "slots": [ + {"slot": "pivot_a", "role_hint": "DM", "start": {"x": 38, "y": 40}}, + {"slot": "pivot_b", "role_hint": "DM", "start": {"x": 38, "y": 60}}, + {"slot": "cb_l", "role_hint": "CB", "start": {"x": 20, "y": 35}}, + {"slot": "cb_r", "role_hint": "CB", "start": {"x": 20, "y": 65}} + ], + "ball": {"holder_slot": "cb_l"}, + "steps": [ + { + "n": 1, + "caption": "Their front two splits our two centre backs and there is no spare man.", + "moves": [] + }, + { + "n": 2, + "caption": "One pivot drops between them, the other slides across to screen alone.", + "moves": [ + {"slot": "pivot_a", "to": {"x": 20, "y": 50}}, + {"slot": "pivot_b", "to": {"x": 42, "y": 52}} + ] + }, + { + "n": 3, + "caption": "The back three plays around the front two while the single screen holds.", + "moves": [], + "ball_to": {"bind_slot": "pivot_a", "trajectory": "ground"} + } + ], + "loop": true + }, + "exemplar_note": "Liverpool under Slot, and Cesc Fabregas's Como. Not a licence: names are editorial reference points only.", + "source_ref": "doc06:2.5", + "content_version": "1.1.0" + }, + { + "code": "rot_wb_asymmetry", + "name": "One wing back high, one deep", + "family": "wide", + "applies_to_formations": ["352", "343", "541"], + "produces_shape": "back four to five", + "trigger": "We are building down one side against a back four.", + "what_moves_json": [ + {"slot": "wb_far", "from": {"x": 35, "y": 10}, "to": {"x": 28, "y": 12}, "becomes": "fullback"}, + { + "slot": "wb_near", + "from": {"x": 35, "y": 90}, + "to": {"x": 82, "y": 92}, + "becomes": "last_line_width" + }, + {"slot": "cb_wide", "from": {"x": 20, "y": 25}, "to": {"x": 26, "y": 30}, "becomes": "wide_cb"} + ], + "coaching_points_json": [ + "The ball-far wing back drops as the ball travels away from him, so the back four exists before the switch is even considered.", + "The near wing back stays on the touchline and does not come to the ball: he is the width, and width that moves inside is not width." + ], + "risk": "The deep wing back is the only cover on his entire flank, so a switch away from the ball finds him alone against two.", + "requires_profile_json": { + "wb_far": { + "archetypes": ["wb_defensive", "wb_inverted"], + "foot": null, + "attributes": ["positional_discipline"] + }, + "wb_near": {"archetypes": ["wb_flyer"], "foot": null, "attributes": ["pace", "pressing_engine"]} + }, + "animation_spec_json": { + "slots": [ + {"slot": "wb_far", "role_hint": "WB", "start": {"x": 35, "y": 10}}, + {"slot": "wb_near", "role_hint": "WB", "start": {"x": 35, "y": 90}}, + {"slot": "cb_wide", "role_hint": "CB", "start": {"x": 20, "y": 25}}, + {"slot": "cm", "role_hint": "CM", "start": {"x": 48, "y": 55}} + ], + "ball": {"holder_slot": "cb_wide"}, + "steps": [ + { + "n": 1, + "caption": "We build down the right and the ball is travelling away from the left.", + "moves": [] + }, + { + "n": 2, + "caption": "The far wing back drops into the back line, the near one holds the last line.", + "moves": [ + {"slot": "wb_far", "to": {"x": 28, "y": 12}}, + {"slot": "wb_near", "to": {"x": 82, "y": 92}} + ] + }, + { + "n": 3, + "caption": "Four build the play, five occupy the last line, and one flank is covered by one player.", + "moves": [{"slot": "cm", "to": {"x": 60, "y": 70}}], + "ball_to": {"bind_slot": "wb_near", "trajectory": "driven"} + } + ], + "loop": true + }, + "exemplar_note": "Simone Inzaghi's Inter, with Dimarco high and Darmian tucked in. Not a licence: names are editorial reference points only.", + "source_ref": "doc06:2.5", + "content_version": "1.1.0" + }, + { + "code": "rot_fb_touchline_swap", + "name": "Winger inside, fullback outside", + "family": "wide", + "applies_to_formations": ["433", "4231", "442"], + "produces_shape": "2-3-5", + "trigger": "Their fullback is tucking narrow to protect the centre back.", + "what_moves_json": [ + { + "slot": "winger", + "from": {"x": 78, "y": 88}, + "to": {"x": 76, "y": 70}, + "becomes": "half_space_receiver" + }, + { + "slot": "fullback", + "from": {"x": 40, "y": 90}, + "to": {"x": 78, "y": 94}, + "becomes": "touchline_width" + } + ], + "coaching_points_json": [ + "They swap in one movement, not one after the other, so their fullback never gets a moment with nobody to track.", + "The winger's first look from the half-space is the pass in behind, because that is the option he did not have on the touchline." + ], + "risk": "The winger is no longer isolated in a one against one, so a qualitative superiority is traded for a positional one. Know which one you wanted.", + "requires_profile_json": { + "winger": { + "archetypes": ["wf_inside_forward", "wf_raumdeuter"], + "foot": "L", + "attributes": ["carrying_1v1", "passing_range"] + }, + "fullback": {"archetypes": ["fb_overlapper"], "foot": null, "attributes": ["pace", "pressing_engine"]} + }, + "animation_spec_json": { + "slots": [ + {"slot": "winger", "role_hint": "W", "start": {"x": 78, "y": 88}}, + {"slot": "fullback", "role_hint": "FB", "start": {"x": 40, "y": 90}}, + {"slot": "eight", "role_hint": "CM", "start": {"x": 55, "y": 70}}, + {"slot": "opp_fb", "role_hint": null, "start": {"x": 70, "y": 82}, "side": "opponent"} + ], + "ball": {"holder_slot": "eight"}, + "steps": [ + { + "n": 1, + "caption": "Their fullback has tucked in narrow, so the touchline is unguarded.", + "moves": [] + }, + { + "n": 2, + "caption": "The winger comes into the half-space as the fullback runs outside him.", + "moves": [ + {"slot": "winger", "to": {"x": 76, "y": 70}, "arc": "inside"}, + {"slot": "fullback", "to": {"x": 78, "y": 94}, "arc": "outside"} + ] + }, + { + "n": 3, + "caption": "The ball goes to whichever of the two their fullback did not follow.", + "moves": [], + "ball_to": {"bind_slot": "winger", "trajectory": "ground"} + } + ], + "loop": true + }, + "exemplar_note": null, + "source_ref": "doc06:2.5", + "content_version": "1.1.0" + }, + { + "code": "rot_false_nine_drop", + "name": "Nine drops, wingers dive the channels", + "family": "front_line", + "applies_to_formations": ["433", "4231", "343"], + "produces_shape": "4-2-4", + "trigger": "The ball reaches a midfielder who is facing forward.", + "what_moves_json": [ + { + "slot": "nine", + "from": {"x": 85, "y": 50}, + "to": {"x": 62, "y": 50}, + "becomes": "between_the_lines" + }, + { + "slot": "winger_l", + "from": {"x": 78, "y": 15}, + "to": {"x": 88, "y": 32}, + "becomes": "channel_runner" + }, + { + "slot": "winger_r", + "from": {"x": 78, "y": 85}, + "to": {"x": 88, "y": 68}, + "becomes": "channel_runner" + } + ], + "coaching_points_json": [ + "The wingers go the instant he drops, not once he has the ball: the runs are what make the drop a problem.", + "This is the same movement the False-9 Drop and Wing Dive card animates, seeded here as a structural change rather than a one-off pattern." + ], + "risk": "Nobody occupies the centre backs, so if the runs do not go the last line has no pin on it at all.", + "requires_profile_json": { + "nine": { + "archetypes": ["nine_false"], + "foot": null, + "attributes": ["passing_range", "positional_discipline"] + }, + "winger_l": {"archetypes": ["wf_channel_runner"], "foot": null, "attributes": ["pace"]}, + "winger_r": {"archetypes": ["wf_channel_runner"], "foot": null, "attributes": ["pace"]} + }, + "animation_spec_json": { + "slots": [ + {"slot": "nine", "role_hint": "ST", "start": {"x": 85, "y": 50}}, + {"slot": "winger_l", "role_hint": "W", "start": {"x": 78, "y": 15}}, + {"slot": "winger_r", "role_hint": "W", "start": {"x": 78, "y": 85}}, + {"slot": "eight", "role_hint": "CM", "start": {"x": 55, "y": 50}} + ], + "ball": {"holder_slot": "eight"}, + "steps": [ + { + "n": 1, + "caption": "The midfielder receives facing forward, which is the only trigger needed.", + "moves": [] + }, + { + "n": 2, + "caption": "The nine drops between the lines and both wingers dive the channels.", + "moves": [ + {"slot": "nine", "to": {"x": 62, "y": 50}}, + {"slot": "winger_l", "to": {"x": 88, "y": 32}, "arc": "inside"}, + {"slot": "winger_r", "to": {"x": 88, "y": 68}, "arc": "inside"} + ] + }, + { + "n": 3, + "caption": "Their centre back either follows and opens the channel, or lets him turn.", + "moves": [], + "ball_to": {"bind_slot": "nine", "trajectory": "ground"} + } + ], + "loop": true + }, + "exemplar_note": "Lionel Messi under Guardiola, Roberto Firmino at Liverpool. Not a licence: names are editorial reference points only.", + "source_ref": "doc06:2.5", + "content_version": "1.1.0" + }, + { + "code": "rot_box_form", + "name": "Two forwards drop to form the box midfield", + "family": "front_line", + "applies_to_formations": ["343", "352", "433"], + "produces_shape": "3-2-2-3", + "trigger": "We are winning the centre and want to keep it.", + "what_moves_json": [ + { + "slot": "forward_l", + "from": {"x": 80, "y": 22}, + "to": {"x": 62, "y": 34}, + "becomes": "box_top" + }, + { + "slot": "forward_r", + "from": {"x": 80, "y": 78}, + "to": {"x": 62, "y": 66}, + "becomes": "box_top" + } + ], + "coaching_points_json": [ + "The two who drop take the half-spaces, not the centre: a box with a flat top is two players marked by one.", + "The wing backs must already be high when the box forms, or the shape has four central players and no width at all." + ], + "risk": "The wide zones are empty except for the wing backs, and two exhausted wing backs is a real cost by the last twenty minutes.", + "requires_profile_json": { + "forward_l": { + "archetypes": ["wf_inside_forward", "ten_between_the_lines"], + "foot": null, + "attributes": ["passing_range", "positional_discipline"] + }, + "forward_r": { + "archetypes": ["wf_inside_forward", "ten_between_the_lines"], + "foot": null, + "attributes": ["passing_range", "positional_discipline"] + } + }, + "animation_spec_json": { + "slots": [ + {"slot": "forward_l", "role_hint": "W", "start": {"x": 80, "y": 22}}, + {"slot": "forward_r", "role_hint": "W", "start": {"x": 80, "y": 78}}, + {"slot": "pivot_l", "role_hint": "CM", "start": {"x": 44, "y": 40}}, + {"slot": "pivot_r", "role_hint": "CM", "start": {"x": 44, "y": 60}} + ], + "ball": {"holder_slot": "pivot_l"}, + "steps": [ + { + "n": 1, + "caption": "The double pivot has the ball and their midfield is even with ours.", + "moves": [] + }, + { + "n": 2, + "caption": "Both wide forwards drop into the half-spaces above the pivot.", + "moves": [ + {"slot": "forward_l", "to": {"x": 62, "y": 34}, "arc": "inside"}, + {"slot": "forward_r", "to": {"x": 62, "y": 66}, "arc": "inside"} + ] + }, + { + "n": 3, + "caption": "Four central players in a box means the carrier always has two forward passes.", + "moves": [], + "ball_to": {"bind_slot": "forward_l", "trajectory": "ground"} + } + ], + "loop": true + }, + "exemplar_note": "Xabi Alonso's Leverkusen, and Manchester City's box seasons. Not a licence: names are editorial reference points only.", + "source_ref": "doc06:2.5", + "content_version": "1.1.0" + }, + { + "code": "rot_press_bait_hold", + "name": "Hold the ball dead to invite the presser", + "family": "first_line", + "applies_to_formations": ["4231", "433", "442"], + "produces_shape": "shape unchanged", + "trigger": "Their first line is hesitating on the edge of pressing.", + "what_moves_json": [ + {"slot": "cb", "from": {"x": 22, "y": 55}, "to": {"x": 22, "y": 55}, "becomes": "bait"}, + { + "slot": "pivot", + "from": {"x": 42, "y": 50}, + "to": {"x": 34, "y": 44}, + "becomes": "bounce_option" + } + ], + "coaching_points_json": [ + "The sole of the foot on the ball, head up, body open. A ball that is still keeps every passing lane open at once.", + "The backward pass here is a trigger, not a retreat, and the whole team should move forward when it is played rather than back.", + "Rehearse the three exits before you rehearse the bait, because the bait without the exit is just a mistake with better posture." + ], + "risk": "It is a genuine risk taken on purpose in our own third, so run it only with players who can execute under pressure, and tell them that plainly.", + "requires_profile_json": { + "cb": { + "archetypes": ["cb_ball_player", "cb_stepping_pivot"], + "foot": null, + "attributes": ["passing_range", "positional_discipline"] + }, + "pivot": {"archetypes": ["six_metronome"], "foot": null, "attributes": ["passing_range"]} + }, + "animation_spec_json": { + "slots": [ + {"slot": "cb", "role_hint": "CB", "start": {"x": 22, "y": 55}}, + {"slot": "pivot", "role_hint": "DM", "start": {"x": 42, "y": 50}}, + {"slot": "fullback", "role_hint": "FB", "start": {"x": 26, "y": 88}}, + {"slot": "opp_striker", "role_hint": null, "start": {"x": 36, "y": 52}, "side": "opponent"} + ], + "ball": {"holder_slot": "cb"}, + "steps": [ + { + "n": 1, + "caption": "Their striker hesitates on the edge of pressing, so the ball stops dead.", + "moves": [] + }, + { + "n": 2, + "caption": "The presser commits, and the pivot shows into the space he has left.", + "moves": [ + {"slot": "pivot", "to": {"x": 34, "y": 44}}, + {"slot": "opp_striker", "to": {"x": 26, "y": 56}} + ] + }, + { + "n": 3, + "caption": "One pass past the first line puts us in a five against four beyond it.", + "moves": [{"slot": "fullback", "to": {"x": 42, "y": 92}}], + "ball_to": {"bind_slot": "pivot", "trajectory": "ground"} + } + ], + "loop": true + }, + "exemplar_note": "Roberto De Zerbi's Brighton. Not a licence: names are editorial reference points only.", + "source_ref": "doc06:2.5", + "content_version": "1.1.0" + }, + { + "code": "rot_gk_plus_one", + "name": "Keeper as the spare man in the build", + "family": "first_line", + "applies_to_formations": ["433", "4231", "442", "352", "343", "541"], + "produces_shape": "3-2 plus keeper", + "trigger": "The opponent presses with one more player than we are building with.", + "what_moves_json": [ + {"slot": "keeper", "from": {"x": 5, "y": 50}, "to": {"x": 18, "y": 50}, "becomes": "spare_man"}, + {"slot": "cb_l", "from": {"x": 20, "y": 35}, "to": {"x": 22, "y": 22}, "becomes": "wide_cb"}, + {"slot": "cb_r", "from": {"x": 20, "y": 65}, "to": {"x": 22, "y": 78}, "becomes": "wide_cb"} + ], + "coaching_points_json": [ + "He steps to the edge of the box and stays there: a keeper who drifts is neither a builder nor a goalkeeper.", + "He is the apex of the first-line rondo, so his job is to make the extra presser wrong, not to hit the longest pass available.", + "Agree the bail-out before the session starts. Every player should know which ball he is allowed to put out of play." + ], + "risk": "Everything behind the keeper is empty, which makes this the single highest consequence rotation in the book.", + "requires_profile_json": { + "keeper": { + "archetypes": ["gk_ball_player", "gk_sweeper"], + "foot": null, + "attributes": ["passing_range", "positional_discipline"] + } + }, + "animation_spec_json": { + "slots": [ + {"slot": "keeper", "role_hint": "GK", "start": {"x": 5, "y": 50}}, + {"slot": "cb_l", "role_hint": "CB", "start": {"x": 20, "y": 35}}, + {"slot": "cb_r", "role_hint": "CB", "start": {"x": 20, "y": 65}}, + {"slot": "six", "role_hint": "DM", "start": {"x": 40, "y": 50}} + ], + "ball": {"holder_slot": "keeper"}, + "steps": [ + { + "n": 1, + "caption": "They press with three against our two centre backs and the pivot.", + "moves": [] + }, + { + "n": 2, + "caption": "The keeper steps to the edge of the box and the defenders widen.", + "moves": [ + {"slot": "keeper", "to": {"x": 18, "y": 50}}, + {"slot": "cb_l", "to": {"x": 22, "y": 22}}, + {"slot": "cb_r", "to": {"x": 22, "y": 78}} + ] + }, + { + "n": 3, + "caption": "Four against three in the first line, and the free man is always one pass away.", + "moves": [{"slot": "six", "to": {"x": 44, "y": 44}}], + "ball_to": {"bind_slot": "cb_r", "trajectory": "ground"} + } + ], + "loop": true + }, + "exemplar_note": "Ederson at Manchester City, Manuel Neuer at Bayern. Not a licence: names are editorial reference points only.", + "source_ref": "doc06:2.5", + "content_version": "1.1.0" + }, + { + "code": "rot_ten_drop_pivot", + "name": "Ten drops beside the pivot", + "family": "pivot", + "applies_to_formations": ["4231"], + "produces_shape": "4-3-3", + "trigger": "Their ten or striker is screening our pivot and we cannot get out.", + "what_moves_json": [{"slot": "ten", "from": {"x": 60, "y": 50}, "to": {"x": 44, "y": 58}, "becomes": "eight"}], + "coaching_points_json": [ + "He drops to the side of the screen, not behind it, so the pass to him does not have to travel through the player causing the problem.", + "Somebody has to replace him between the lines within a few seconds, usually a winger coming inside, or we have simply run away from the space." + ], + "risk": "We surrender the player between the lines, which is the whole point of the shape, so this is a temporary fix and never a setting.", + "requires_profile_json": { + "ten": { + "archetypes": ["ten_between_the_lines", "ten_pressing_ten"], + "foot": null, + "attributes": ["passing_range", "positional_discipline"] + } + }, + "animation_spec_json": { + "slots": [ + {"slot": "ten", "role_hint": "AM", "start": {"x": 60, "y": 50}}, + {"slot": "pivot", "role_hint": "DM", "start": {"x": 38, "y": 45}}, + {"slot": "cb", "role_hint": "CB", "start": {"x": 22, "y": 55}}, + {"slot": "winger", "role_hint": "W", "start": {"x": 74, "y": 18}} + ], + "ball": {"holder_slot": "cb"}, + "steps": [ + { + "n": 1, + "caption": "Their ten stands on our pivot and the ball cannot leave the first line.", + "moves": [] + }, + { + "n": 2, + "caption": "Our ten drops beside the pivot to make a midfield three.", + "moves": [{"slot": "ten", "to": {"x": 44, "y": 58}}] + }, + { + "n": 3, + "caption": "The three plays out, and a winger comes inside to replace him between the lines.", + "moves": [{"slot": "winger", "to": {"x": 62, "y": 34}, "arc": "inside"}], + "ball_to": {"bind_slot": "ten", "trajectory": "ground"} + } + ], + "loop": true + }, + "exemplar_note": null, + "source_ref": "doc06:2.5", + "content_version": "1.1.0" + }, + { + "code": "rot_overload_isolate", + "name": "Overload one side, switch to the isolated winger", + "family": "front_line", + "applies_to_formations": ["433", "4231", "442", "352", "343", "541"], + "produces_shape": "shape unchanged", + "trigger": "We have a winger who wins his one against one.", + "what_moves_json": [ + {"slot": "eight", "from": {"x": 55, "y": 50}, "to": {"x": 58, "y": 76}, "becomes": "overload"}, + {"slot": "nine", "from": {"x": 85, "y": 50}, "to": {"x": 80, "y": 68}, "becomes": "overload"}, + { + "slot": "far_winger", + "from": {"x": 78, "y": 12}, + "to": {"x": 78, "y": 8}, + "becomes": "isolated_1v1" + } + ], + "coaching_points_json": [ + "The far winger stays wide and does nothing, which is the hardest instruction in the game to follow for ninety minutes.", + "The switch is prepared by a player who can see it before he receives, which usually means the ball goes backward once first." + ], + "risk": "The switch has to be prepared rather than hopeful, because an unprepared long diagonal is a turnover inside our own build shape.", + "requires_profile_json": { + "far_winger": { + "archetypes": ["wf_touchline_winger", "wf_inside_forward"], + "foot": null, + "attributes": ["carrying_1v1", "pace"] + } + }, + "animation_spec_json": { + "slots": [ + {"slot": "eight", "role_hint": "CM", "start": {"x": 55, "y": 50}}, + {"slot": "nine", "role_hint": "ST", "start": {"x": 85, "y": 50}}, + {"slot": "far_winger", "role_hint": "W", "start": {"x": 78, "y": 12}}, + {"slot": "six", "role_hint": "DM", "start": {"x": 42, "y": 50}} + ], + "ball": {"holder_slot": "eight"}, + "steps": [ + { + "n": 1, + "caption": "Six or seven players commit to the right side and their block slides across.", + "moves": [] + }, + { + "n": 2, + "caption": "The overload holds the ball while the far winger simply stays wide.", + "moves": [ + {"slot": "eight", "to": {"x": 58, "y": 76}}, + {"slot": "nine", "to": {"x": 80, "y": 68}}, + {"slot": "far_winger", "to": {"x": 78, "y": 8}} + ] + }, + { + "n": 3, + "caption": "The ball goes back to the pivot, then across, and the winger is one against one.", + "moves": [], + "ball_to": {"bind_slot": "far_winger", "trajectory": "driven"} + } + ], + "loop": true + }, + "exemplar_note": null, + "source_ref": "doc06:2.5", + "content_version": "1.1.0" + } + ] +} diff --git a/seeds/unit_balance_rules.json b/seeds/unit_balance_rules.json new file mode 100644 index 0000000..80993df --- /dev/null +++ b/seeds/unit_balance_rules.json @@ -0,0 +1,211 @@ +{ + "content_version": "1.1.0", + "table": "unit_balance_rules", + "note": "doc 06 section 2.6 and 3.1: the generalisation of role_clashes from a pair to a whole unit, reusing the same evaluation shape so the two engines read alike. Every rule counts duties_json across the archetypes selected for one unit. rule_kind requires_duty uses min_count, max_duty and max_same_archetype use max_count, and max_same_archetype carries no duty (it counts repeated archetype codes). warning_copy always reads as a check on intent, never as an error: doc 06 is explicit that a coach may want the imbalanced version on purpose, and several of the named archetype_combinations rows deliberately trip a rule here.", + "items": [ + { + "code": "mt_needs_a_tempo_setter", + "unit": "midfield_three", + "rule_kind": "requires_duty", + "duty": "tempo", + "min_count": 1, + "max_count": null, + "warning_copy": "Nobody in this trio is picked to set the tempo, so the ball moves at whatever speed it arrives. Gegenpress midfields accept that on purpose. Check that it is the plan here.", + "severity": "warning", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "mt_one_tempo_setter", + "unit": "midfield_three", + "rule_kind": "max_duty", + "duty": "tempo", + "min_count": null, + "max_count": 1, + "warning_copy": "Two players in this trio set the tempo, which is exactly how a rotating double pivot is meant to work. Check that they have agreed who accelerates once the line is broken, or both will keep recycling.", + "severity": "note", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "mt_needs_progression", + "unit": "midfield_three", + "rule_kind": "requires_duty", + "duty": "progression", + "min_count": 1, + "max_count": null, + "warning_copy": "Nobody in this trio breaks a line by pass or by carry, so it will circulate and hand the last thirty metres to the front three. Check the front three can carry that.", + "severity": "warning", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "mt_needs_rest_defence", + "unit": "midfield_three", + "rule_kind": "requires_duty", + "duty": "rest_defence", + "min_count": 1, + "max_count": null, + "warning_copy": "Nobody in this trio holds the middle when possession turns over, so the rest shape is a three plus one rather than a three plus two. Check the back line is happy defending that.", + "severity": "warning", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "mt_one_box_threat", + "unit": "midfield_three", + "rule_kind": "max_duty", + "duty": "box_threat", + "min_count": null, + "max_count": 1, + "warning_copy": "Two box crashers means both arrive in their area and neither is home when the ball comes back. Coaches chasing a game pick this deliberately, so treat it as a check on intent.", + "severity": "note", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "mt_one_of_each_archetype", + "unit": "midfield_three", + "rule_kind": "max_same_archetype", + "duty": null, + "min_count": null, + "max_count": 1, + "warning_copy": "Two players in this trio are on the same archetype, so the trio does one job twice. Two half-space creators, for instance, leaves nobody to win the ball back. Check the repeat is deliberate.", + "severity": "note", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "dp_needs_a_controller", + "unit": "double_pivot", + "rule_kind": "requires_duty", + "duty": "tempo", + "min_count": 1, + "max_count": null, + "warning_copy": "Neither pivot player is picked to control the ball, so the pair wins it and then has to move it on quickly. That suits a direct identity. Check it suits this one.", + "severity": "warning", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "dp_needs_rest_defence", + "unit": "double_pivot", + "rule_kind": "requires_duty", + "duty": "rest_defence", + "min_count": 1, + "max_count": null, + "warning_copy": "Neither pivot player is picked to hold the space in front of the back line, which is the one job this shape exists for. Check who screens when both of them go forward.", + "severity": "warning", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "dp_one_of_each_archetype", + "unit": "double_pivot", + "rule_kind": "max_same_archetype", + "duty": null, + "min_count": null, + "max_count": 1, + "warning_copy": "Both pivot players are on the same archetype. Bible 4.2 calls the two-destroyer version the sterile pivot: the pair does one job twice and halves the value of the shape. Check the repeat is deliberate.", + "severity": "note", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "ft_needs_a_pin", + "unit": "front_three", + "rule_kind": "requires_duty", + "duty": "pin", + "min_count": 1, + "max_count": null, + "warning_copy": "Nobody in this front three holds the last line, so their centre backs are free to step up and squeeze the space the midfield wants. Check who occupies them.", + "severity": "warning", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "ft_needs_progression", + "unit": "front_three", + "rule_kind": "requires_duty", + "duty": "progression", + "min_count": 1, + "max_count": null, + "warning_copy": "Nobody in this front three is picked to beat a defender or carry past a line, so every chance has to be built behind them. Check the midfield can do that.", + "severity": "warning", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "sp_needs_a_pin", + "unit": "strike_pair", + "rule_kind": "requires_duty", + "duty": "pin", + "min_count": 1, + "max_count": null, + "warning_copy": "Neither striker holds the last line, so the pair drops into the same space the midfield already occupies. Check who stops the centre backs stepping forward.", + "severity": "warning", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "bl_needs_a_stepper", + "unit": "back_line", + "rule_kind": "requires_duty", + "duty": "press_trigger", + "min_count": 1, + "max_count": null, + "warning_copy": "No defender in this line steps out to meet the ball in front of it, so the space between the lines is free all afternoon. Check whether the midfield screens it instead.", + "severity": "warning", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "bl_needs_a_coverer", + "unit": "back_line", + "rule_kind": "requires_duty", + "duty": "rest_defence", + "min_count": 1, + "max_count": null, + "warning_copy": "No defender in this line is picked to cover the space behind it. Behind a high line that is a straight bet on the goalkeeper, so check the goalkeeper is picked for it.", + "severity": "warning", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "bl_one_stepper", + "unit": "back_line", + "rule_kind": "max_duty", + "duty": "press_trigger", + "min_count": null, + "max_count": 1, + "warning_copy": "Two front-foot defenders in the same line means the depth behind them belongs to the goalkeeper. Sides with a sweeper keeper choose this on purpose, so check it is a choice.", + "severity": "note", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "wu_needs_width", + "unit": "wide_unit", + "rule_kind": "requires_duty", + "duty": "width", + "min_count": 1, + "max_count": null, + "warning_copy": "Neither player on this flank holds the touchline, so the pitch is narrow on this side and their fullback can defend inside. Check where the width comes from.", + "severity": "warning", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + }, + { + "code": "wu_one_width", + "unit": "wide_unit", + "rule_kind": "max_duty", + "duty": "width", + "min_count": null, + "max_count": 1, + "warning_copy": "Both players on this flank hold the touchline, so nobody occupies the half-space between them and the centre. Check which of the two is meant to come inside.", + "severity": "note", + "source_ref": "doc06:2.6", + "content_version": "1.1.0" + } + ] +}