Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
cec879c
chore(backlog): T-011 done
BrandanBurgess Jul 16, 2026
d0a1e4f
docs(agent): orchestrator state snapshot for session handoff
BrandanBurgess Jul 16, 2026
613cad2
feat(screens): whiteboard page with toolbar, view menu, record/save (…
BrandanBurgess Jul 16, 2026
b5d7160
merge: T-030 whiteboard page into integration
BrandanBurgess Jul 16, 2026
24ccc96
chore(backlog): T-030 done, T-031 + T-033 doing
BrandanBurgess Jul 16, 2026
23e21ff
feat(screens): patterns page with libraries, chips, search, details (…
BrandanBurgess Jul 16, 2026
880efb4
merge: T-031 patterns page into integration
BrandanBurgess Jul 16, 2026
733ac3f
feat(screens): roster page with CRUD, sliders, double-exposure warnin…
BrandanBurgess Jul 16, 2026
598c97a
fix(infra): seed database in e2e boot path so fresh environments pass…
BrandanBurgess Jul 16, 2026
b165909
merge: T-031 seed-on-boot fix into integration
BrandanBurgess Jul 16, 2026
09c9fe2
merge: reconcile main squash history into integration
BrandanBurgess Jul 16, 2026
ea0930f
Merge remote-tracking branch 'origin/main' into integration
BrandanBurgess Jul 16, 2026
7fd5a3a
merge: T-033 roster page into integration (resolve nav shell, schemas…
BrandanBurgess Jul 16, 2026
add1c38
chore(backlog): T-031 done, T-033 pr, T-032 + T-034 doing
BrandanBurgess Jul 16, 2026
e8926a7
Merge remote-tracking branch 'origin/main' into integration
BrandanBurgess Jul 16, 2026
f331829
chore(backlog): T-033 done
BrandanBurgess Jul 16, 2026
c634850
feat(screens): formations page with keystones, keycards, rondo map (T…
BrandanBurgess Jul 16, 2026
877ad0d
merge: T-032 formations page into integration
BrandanBurgess Jul 16, 2026
35a07a2
chore(e2e): drop duplicated nav assertion left by merge
BrandanBurgess Jul 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
from fastapi import FastAPI

from app.routers import auth, library, roster, teams, whiteboard
from app.routers import auth, formations, library, roster, teams, whiteboard

app = FastAPI(title="Patterns of Play API")
app.include_router(auth.router)
app.include_router(teams.router)
app.include_router(whiteboard.router)
app.include_router(library.router)
app.include_router(roster.router)
app.include_router(formations.router)


@app.get("/api/health")
Expand Down
93 changes: 93 additions & 0 deletions backend/app/routers/formations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"""Formations, keystones, and the rondo map (doc 03 section 5, Bible 4,
3G.2; Brief step 18; the Formations page's board-first render). Library
world content, same reasoning as app/routers/library.py: no team_id
anywhere (Formation/FormationKeystone/RondoZone carry none), so this only
requires an authenticated user, not the team-scoped query layer.
"""

from collections import defaultdict

from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session

from app.deps import get_current_user, get_db
from app.models import Formation, FormationKeystone, RondoZone, User
from app.schemas import FormationKeystoneOut, FormationOut, FormationPositionOut, RondoZoneOut

router = APIRouter(prefix="/api", tags=["formations"])

# doc 03 section 5's six MVP presets, in the Bible's own 4.1-4.6 order
# (matches the seed file and every design PNG's preset list order: 4-3-3,
# 4-2-3-1, 4-4-2, 3-5-2, 3-4-3, 5-4-1). Formation.code has no sequence
# column of its own, so this is the one place that order is asserted.
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.
ZONE_ORDER = ("first_line", "midfield_box", "flank_corridor", "last_line", "counterpress")


def _order_index(value: str, order: tuple[str, ...]) -> int:
try:
return order.index(value)
except ValueError:
return len(order)


@router.get("/formations", response_model=list[FormationOut])
def list_formations(
_current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> list[FormationOut]:
formations = sorted(db.query(Formation).all(), key=lambda f: _order_index(f.code, FORMATION_ORDER))

keystones_by_code: dict[str, list[FormationKeystone]] = defaultdict(list)
for keystone in db.query(FormationKeystone).all():
keystones_by_code[keystone.formation_code].append(keystone)

zones_by_code: dict[str, list[RondoZone]] = defaultdict(list)
for zone in db.query(RondoZone).all():
zones_by_code[zone.formation_code].append(zone)

result: list[FormationOut] = []
for formation in formations:
zones = sorted(
zones_by_code.get(formation.code, []),
key=lambda z: _order_index(z.zone_key, ZONE_ORDER),
)
result.append(
FormationOut(
code=formation.code,
name=formation.name,
shape_blurb=formation.shape_blurb,
strengths=formation.strengths_json,
vulnerabilities=formation.vulnerabilities_json,
natural_identities=formation.natural_identities,
positions=[
FormationPositionOut(
slot=p["slot"],
position_code=p["position_code"],
x=p["x"],
y=p["y"],
)
for p in formation.positions_json
],
keystones=[
FormationKeystoneOut(slot=k.slot, title=k.title, blurb=k.blurb)
for k in keystones_by_code.get(formation.code, [])
],
rondo_zones=[
RondoZoneOut(
zone_key=z.zone_key,
rondo_name=z.rondo_name,
teaches=z.teaches,
polygon=z.polygon_json,
trains_pattern_codes=z.trains_pattern_codes,
)
for z in zones
],
)
)
return result
66 changes: 65 additions & 1 deletion backend/app/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,15 @@

from pydantic import BaseModel, ConfigDict, EmailStr, Field

from app.specs import AnimationSpec, BoardSnapshot, BoardToken, ConfirmedLane, Keyframe, ZonesVisible
from app.specs import (
AnimationSpec,
BoardSnapshot,
BoardToken,
ConfirmedLane,
Keyframe,
ModelPoint,
ZonesVisible,
)

RoleOnTeam = Literal["coach", "player"]

Expand Down Expand Up @@ -289,3 +297,59 @@ class CoachRosterOut(RosterOut):
based on the caller's role_on_team, never both from one shared model."""

fit_warnings: list[FitWarningOut]


# ---------------------------------------------------------------------------
# Formations, keystones, rondo map (doc 03 section 5, Bible 4/3G.2; Brief
# step 18; T-032). Library-world content like LibraryItemOut above: no
# team_id, visible to both roles, read-only to every team member.
# ---------------------------------------------------------------------------


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."""

slot: str
position_code: str
x: float
y: float


class FormationKeystoneOut(BaseModel):
"""One formation_keystones row (Bible Section 4 keystone copy): drives
both the on-board pulsing keycard (tap the token at this slot) and the
Details panel's "every keystone blurb" list (Brief step 18 DoD)."""

slot: str
title: str
blurb: str


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."""

zone_key: str
rondo_name: str
teaches: str
polygon: list[ModelPoint]
trains_pattern_codes: list[str]


class FormationOut(BaseModel):
"""GET /api/formations. Keystones and rondo zones are embedded per
formation (not separate endpoints): the Formations page's browse sheet
needs every preset's full detail up front, the same one-round-trip
shape the Patterns page's listLibraryItems already follows."""

code: str
name: str
shape_blurb: str
strengths: list[str]
vulnerabilities: list[str]
natural_identities: list[str]
positions: list[FormationPositionOut]
keystones: list[FormationKeystoneOut]
rondo_zones: list[RondoZoneOut]
130 changes: 130 additions & 0 deletions backend/tests/test_formations_routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
"""Formations route (doc 03 section 5; Brief step 18): GET /api/formations
serves the six seeded presets with their keystones and rondo zones
embedded. Not team-scoped (Formation/FormationKeystone/RondoZone carry no
team_id), but still requires authentication like every other route. Seeds
via the real scripts/seed.py loader (same in-process import convention as
test_seed_content.py's idempotency test) rather than hand-built fixtures,
so this exercises the actual seeded content: 6 formations, 13 keystones,
5 rondo zones (all on 433, per seeds/rondo_zones.json's own note).
"""

import importlib.util
import pathlib
import sys

import pytest
from fastapi.testclient import TestClient

from app.main import app

REPO_ROOT = pathlib.Path(__file__).resolve().parents[2]


def _import_seed_module():
spec = importlib.util.spec_from_file_location("pop_seed_script_formations", 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


@pytest.fixture
def client() -> TestClient:
seed = _import_seed_module()
assert seed.main() == 0
return TestClient(app)


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") -> TestClient:
c = TestClient(app)
_register(c, email=email, role="coach", display_name="Coach Test")
c.post("/api/teams", json={"name": f"Team for {email}"})
return c


def _player_on_team(coach: TestClient, email: str) -> TestClient:
join_code = coach.get("/api/teams/current").json()["join_code"]
p = TestClient(app)
_register(p, email=email, role="player", display_name="Player Test")
p.post("/api/teams/join", json={"join_code": join_code})
return p


def test_list_formations_requires_authentication(client: TestClient) -> None:
assert client.get("/api/formations").status_code == 401


def test_list_formations_returns_all_six_in_bible_order(client: TestClient) -> None:
coach = _coach_with_team()
response = coach.get("/api/formations")
assert response.status_code == 200
codes = [f["code"] for f in response.json()]
assert codes == ["433", "4231", "442", "352", "343", "541"]


def test_players_can_browse_formations_too(client: TestClient) -> None:
coach = _coach_with_team()
player = _player_on_team(coach, email="player@example.com")
assert len(player.get("/api/formations").json()) == 6


def test_formation_shape_includes_positions_strengths_and_vulnerabilities(client: TestClient) -> None:
coach = _coach_with_team()
formations = {f["code"]: f for f in coach.get("/api/formations").json()}
f433 = formations["433"]
assert f433["name"] == "4-3-3"
assert len(f433["positions"]) == 11
assert any(p["slot"] == "six" and p["position_code"] == "DM" for p in f433["positions"])
assert len(f433["strengths"]) >= 1
assert len(f433["vulnerabilities"]) >= 1


def test_every_keystone_tap_target_has_a_slot_title_and_blurb(client: TestClient) -> None:
coach = _coach_with_team()
formations = {f["code"]: f for f in coach.get("/api/formations").json()}
f433 = formations["433"]
keystone_slots = {k["slot"] for k in f433["keystones"]}
assert keystone_slots == {"six", "st", "eight_l"}
six = next(k for k in f433["keystones"] if k["slot"] == "six")
assert six["title"] == "The 6 (single pivot)"
assert "elite positional discipline" in six["blurb"]

f4231 = formations["4231"]
assert {k["slot"] for k in f4231["keystones"]} == {"am", "dm_l"}


def test_rondo_zones_show_their_rondo_and_linked_patterns(client: TestClient) -> None:
coach = _coach_with_team()
formations = {f["code"]: f for f in coach.get("/api/formations").json()}
f433 = formations["433"]
zones = {z["zone_key"]: z for z in f433["rondo_zones"]}
assert set(zones) == {"first_line", "midfield_box", "flank_corridor", "last_line", "counterpress"}

midfield = zones["midfield_box"]
assert midfield["rondo_name"] == "5v3 (the midfield box)"
assert midfield["trains_pattern_codes"] == ["B8", "A5"]
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).
f442 = formations["442"]
assert f442["rondo_zones"] == []


def test_em_dash_never_appears_in_a_formations_response(client: TestClient) -> None:
coach = _coach_with_team()
body = coach.get("/api/formations").text
assert "—" not in body
2 changes: 1 addition & 1 deletion docs/agent/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Model: sonnet default; opus = hard ticket, never downgrade.
| T-030 | Whiteboard page (PNG 01-05, 14, 34): toolbar, view menu, record/save into My Patterns | 16 | screens | sonnet | T-022, T-004 | T-031 | done |
| T-031 | Patterns page (PNG 05-10, 29-31, 15-18, 35): sheet w/ 3 libraries, chips, search, meta bar, details panels | 17 | screens | sonnet | T-022, T-011 | T-030 | done |
| T-032 | Formations page (PNG 11, 19, 37-39, 43) + keystone pulse/keycards + Rondo Map (PNG 32, 36) | 18 | screens | sonnet | T-031 | T-033 | doing |
| T-033 | Roster page (PNG 12, 20): CRUD, chips, 6 sliders, double-exposure warning coach-only | 19 | screens | sonnet | T-004, T-011 | T-032 | pr |
| T-033 | Roster page (PNG 12, 20): CRUD, chips, 6 sliders, double-exposure warning coach-only | 19 | screens | sonnet | T-004, T-011 | T-032 | done |
| T-034 | Identity page (PNG 13, 33, 40-42, 44, 45): 4 scripted animations, 2 static shapes, detail slots, pass-risk, cult corner | 20 | screens | sonnet | T-031 | T-033 | doing |
| T-040 | Role gating UI + API 403 enforcement, permission test suite both roles (Brief §3 table, every row) | 21 | collab | sonnet | T-030..T-034 | T-041 | todo |
| T-041 | Playstyle suggestion flow (PNG 24, 25, 27) | 22 | collab | sonnet | T-033 | T-040 | todo |
Expand Down
Loading
Loading