Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 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
ce9695c
feat(screens): identity page with reference teams, pass-risk, cult co…
BrandanBurgess Jul 16, 2026
62c8b24
Merge remote-tracking branch 'origin/main' into integration
BrandanBurgess Jul 16, 2026
02cf1c3
merge: T-034 identity page into integration (all five nav entries live)
BrandanBurgess Jul 16, 2026
f8fde64
Merge remote-tracking branch 'origin/main' into integration
BrandanBurgess Jul 16, 2026
4d7ff05
chore(backlog): T-032 + T-034 done, screens phase complete
BrandanBurgess Jul 16, 2026
d1ce7c1
chore(backlog): T-040 + T-041 doing
BrandanBurgess Jul 16, 2026
abb4823
feat(collab): role gating suite, API enforcement audit (T-040)
BrandanBurgess Jul 16, 2026
817df6d
merge: T-040 role gating suite into integration
BrandanBurgess Jul 16, 2026
8732131
chore(backlog): founder decisions recorded, add T-012 + T-043
BrandanBurgess Jul 16, 2026
0151d7b
feat(collab): playstyle suggestion flow (T-041)
BrandanBurgess Jul 16, 2026
27c9211
Merge remote-tracking branch 'origin/main' into integration
BrandanBurgess Jul 16, 2026
2131cc4
merge: T-041 suggestion flow into integration
BrandanBurgess Jul 16, 2026
0e4cf0c
chore(backlog): T-041 pr, T-012 doing
BrandanBurgess Jul 16, 2026
302040d
Merge remote-tracking branch 'origin/main' into integration
BrandanBurgess Jul 16, 2026
7f69ea2
chore(backlog): T-041 done
BrandanBurgess Jul 16, 2026
ea71eae
feat(platform): role-scoped join codes, head coach member management …
BrandanBurgess Jul 16, 2026
0282683
merge: T-043 team management into integration
BrandanBurgess Jul 16, 2026
7116ee6
feat(content): identity age_hint column, migration, Bible backfill (T…
BrandanBurgess Jul 16, 2026
335e147
Merge remote-tracking branch 'origin/main' into integration
BrandanBurgess Jul 16, 2026
c3f273c
merge: T-012 identity age_hint into integration
BrandanBurgess Jul 16, 2026
0fc6f18
merge-fix: re-point T-012 migration to 0005 after T-043's 0004
BrandanBurgess Jul 16, 2026
41763d4
Merge remote-tracking branch 'origin/main' into integration
BrandanBurgess Jul 16, 2026
f23238e
docs(agent): session 2 handoff snapshot, T-012 + T-043 done
BrandanBurgess Jul 16, 2026
be6e74c
feat(collab): sessions draft builder, send, receipts, player view (T-…
BrandanBurgess Aug 7, 2026
52f8113
feat(demo): scripts/seed_demo.py and a `make demo` target
BrandanBurgess Aug 7, 2026
274dda1
feat(screens): phone pass, styled auth screens, shared ghost button (…
BrandanBurgess Aug 7, 2026
6dd1273
feat(verify): demo-path journey, permission gate in CI, one search ma…
BrandanBurgess Aug 7, 2026
8e61cb0
feat(docs): screenshot capture script, docs/screenshots, and the README
BrandanBurgess Aug 7, 2026
08a55a5
feat(platform): deployable image, single-origin serving, Render bluep…
BrandanBurgess Aug 7, 2026
4659426
fix(ci): pin the dev tooling so CI and a checkout cannot drift
BrandanBurgess Aug 7, 2026
1e78386
merge: reconcile squash-merged main back into integration
BrandanBurgess Aug 7, 2026
fa5ca0a
docs(readme): live demo URL and a deployment section
BrandanBurgess Aug 7, 2026
ab3cc46
merge: reconcile squash-merged main back into integration
BrandanBurgess Aug 7, 2026
559d773
docs(epic): commission Epic T-100 Tactics Lab
BrandanBurgess Aug 7, 2026
33ee04f
docs(scope): amend Brief scope table for Tactical Depth epic (T-100)
BrandanBurgess Aug 7, 2026
5ea7bd2
merge: T-100 scope amendment into integration
BrandanBurgess Aug 7, 2026
e04b188
chore(backlog): T-100 done
BrandanBurgess Aug 7, 2026
22bf9bc
feat(board): superiority engine, JdP grid and matchup read (T-104)
BrandanBurgess Aug 7, 2026
a95bf77
feat(schema): Tactics Lab schema, migration 0006, scoped tables (T-101)
BrandanBurgess Aug 7, 2026
d20324a
merge: T-104 superiority engine into integration
BrandanBurgess Aug 7, 2026
e39bd90
merge: T-101 tactics lab schema into integration
BrandanBurgess Aug 7, 2026
deaa8e9
chore(backlog): T-101 and T-104 done
BrandanBurgess Aug 7, 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 CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ You are the ORCHESTRATOR (largest model in this session). You plan, dispatch, re
| docs/source/03_Data_Model_and_Tactical_Content_Spec.md | Schema, seed rules, animation spec format | Platform + content-seeder + board tickets |
| docs/source/04_Tech_Stack_Decision_Note.md | Stack decisions (FastAPI, SQLAlchemy 2 + Alembic, SQLite WAL, Litestream, React + Vite) | T-001 and any infra question |
| docs/source/01_PRD_v2_Patterns_of_Play.md | Product intent, later phases | Ambiguity resolution only |
On conflict: Brief wins scope, design README wins visuals and permissions, Bible wins content, doc 03 wins schema. Do not relitigate doc 04 decisions.
| docs/source/06_Tactical_Depth_Spec.md | Tactical Depth epic (T-100 series): the scope amendment, formation matchup overlay, Rondo Map (6 zones, all formations), auto footedness, unit-balance clash warnings | Any T-100 epic ticket, section by section |
On conflict: Brief wins scope, design README wins visuals and permissions, Bible wins content, doc 03 wins schema. Do not relitigate doc 04 decisions. Within the T-100 epic, doc 06 wins on everything doc 06 speaks to; doc 03 still wins on general schema conventions, the design README still wins on visual language and the permission table, the Bible still wins wherever it already speaks, and doc 04 stack decisions are not reopened.

## Non-negotiable rules (all agents)
1. One ticket = one branch = one worktree = one PR. Never touch main.
Expand Down
22 changes: 22 additions & 0 deletions backend/app/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
formations.py section 5 formations, keystones, rondo zones, identities (T-004)
team_content.py section 4.2/4.3 saved_patterns, boards (T-004)
sessions.py section 6 sessions, session_items, session_receipts (T-004)
tactics.py doc 06 section 3 Tactics Lab: formation phases, rotation
systems, position archetypes, archetype combinations,
unit balance rules, formation matchups, team formations
and their slots (T-101)

Every name is re-exported here so callers keep writing
`from app.models import X` (T-003 routers and deps already do this), and
Expand All @@ -27,6 +31,16 @@
RoleSynergy,
)
from app.models.sessions import SessionItem, SessionReceipt, TrainingSession
from app.models.tactics import (
ArchetypeCombination,
FormationMatchup,
FormationPhase,
PositionArchetype,
RotationSystem,
TeamFormation,
TeamFormationSlot,
UnitBalanceRule,
)
from app.models.team_content import Board, SavedPattern

__all__ = [
Expand All @@ -50,4 +64,12 @@
"TrainingSession",
"SessionItem",
"SessionReceipt",
"FormationPhase",
"RotationSystem",
"PositionArchetype",
"ArchetypeCombination",
"UnitBalanceRule",
"FormationMatchup",
"TeamFormation",
"TeamFormationSlot",
]
22 changes: 19 additions & 3 deletions backend/app/models/formations.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
team_id anywhere in this module.
"""

from sqlalchemy import ForeignKey, JSON, String, Text
from sqlalchemy import ForeignKey, Integer, JSON, String, Text
from sqlalchemy.orm import Mapped, mapped_column

from app.db import Base
Expand Down Expand Up @@ -41,19 +41,35 @@ class FormationKeystone(Base):
class RondoZone(Base):
"""Bible 3G.2 rondo map; 3G.1 rondo table seeds the metadata even
though the session planner itself is deferred. No id in doc 03;
(formation_code, zone_key) is the natural key."""
(formation_code, zone_key) is the natural key.

doc 06 section 3.1 amendment (T-101, migration 0006): three new
columns, plus a data migration that splits the single 4-3-3
`flank_corridor` row into `flank_corridor_left` and
`flank_corridor_right` so every formation can eventually carry both
flanks instead of one polygon standing in for both sides."""

__tablename__ = "rondo_zones"

formation_code: Mapped[str] = mapped_column(ForeignKey("formations.code"), primary_key=True)
# first_line | midfield_box | flank_corridor | last_line | counterpress
# first_line | midfield_box | flank_corridor_left | flank_corridor_right
# | last_line | counterpress
zone_key: Mapped[str] = mapped_column(String(30), primary_key=True)
polygon_json: Mapped[list] = mapped_column(JSON, nullable=False)
rondo_name: Mapped[str] = mapped_column(String(120), nullable=False)
teaches: Mapped[str] = mapped_column(Text, nullable=False)
trains_pattern_codes: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
source_ref: Mapped[str | None] = mapped_column(String(60), nullable=True)
content_version: Mapped[str | None] = mapped_column(String(20), nullable=True)
# Fallback label when no opposition is placed, e.g. '4v2'. Nullable:
# T-101 adds the column but seeds no new content (T-102/T-103's job),
# and inventing a label for the five still-unseeded formations is a
# content decision this ticket does not own.
canonical_rondo: Mapped[str | None] = mapped_column(String(30), nullable=True)
# polygon | ball_relative_circle. Defaults to 'polygon' because every
# zone seeded so far (and every zone this migration creates) is one.
zone_kind: Mapped[str] = mapped_column(String(30), nullable=False, default="polygon")
radius: Mapped[int | None] = mapped_column(Integer, nullable=True)


class Identity(Base):
Expand Down
244 changes: 244 additions & 0 deletions backend/app/models/tactics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
"""Tactics Lab (Epic T-100, doc 06 section 3): formation phases, rotation
systems, position archetypes and their combinations/balance rules,
formation matchups, and a team's own saved formation setups.

Two worlds in this one module, same reasoning as roster.py mixing
PositionCode/Role (library) with Player/PlayerAttribute (team) in one
file because they are one ticket's thematic slice:

- library world, no team_id anywhere: FormationPhase, RotationSystem,
PositionArchetype, ArchetypeCombination, UnitBalanceRule,
FormationMatchup. Seeded, read-only to teams. T-101 creates these
tables empty; T-102/T-103 own the seed content.
- team world: TeamFormation carries team_id directly.
TeamFormationSlot carries no team_id of its own and scopes
transitively through team_formation_id -> team_formations.team_id,
the same shape player_attributes already uses through player_id ->
players.team_id (app/scoped.py TeamScope.query_via).

Fixed vocabularies (phase, family, rule_kind, severity, route_kind, unit,
zone_kind, foot_hint) are plain strings here, validated at the Pydantic
layer when a route is built on top of these tables (T-108), not as DB
enums or CHECK constraints: same precedent as role_on_team
(app/models/platform.py) and attribute_key (app/models/roster.py).
"""

from datetime import datetime

from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, JSON, String, Text
from sqlalchemy.orm import Mapped, mapped_column

from app.db import Base
from app.models._util import utcnow


# ---------------------------------------------------------------------------
# Library world (doc 06 section 3.1)
# ---------------------------------------------------------------------------


class FormationPhase(Base):
"""A named shape a base formation morphs into in one phase of play
(doc 06 section 3.1). Natural key (formation_code, variant_code), same
style as formation_keystones. The morph animation binds by slot, so
positions_json's slot set must exactly equal the base formation's slot
set: enforced by the seed validator (T-102/T-103), not here."""

__tablename__ = "formation_phases"

formation_code: Mapped[str] = mapped_column(ForeignKey("formations.code"), primary_key=True)
variant_code: Mapped[str] = mapped_column(String(30), primary_key=True)
# in_possession | out_of_possession | rest_defence | transition
phase: Mapped[str] = mapped_column(String(20), nullable=False)
name: Mapped[str] = mapped_column(String(120), nullable=False)
shape_label: Mapped[str] = mapped_column(String(20), nullable=False)
blurb: Mapped[str] = mapped_column(String(300), nullable=False)
positions_json: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
trigger: Mapped[str] = mapped_column(Text, nullable=False)
# '3+2' | '2+3' | '4+1' | '5+2', nullable per doc 06.
rest_shape: Mapped[str | None] = mapped_column(String(10), nullable=True)
reference_code: Mapped[str | None] = mapped_column(
ForeignKey("identities.code"), nullable=True
)
uses_rotations: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
source_ref: Mapped[str | None] = mapped_column(String(60), nullable=True)
content_version: Mapped[str | None] = mapped_column(String(20), nullable=True)


class RotationSystem(Base):
"""A named positional rotation, e.g. a fullback inverting into the
pivot (doc 06 section 3.1). `risk` is required, not nullable: doc 06
is explicit that "a rotation without a stated cost fails the
validator", so the column itself refuses to be empty even before the
seed validator (T-102/T-103) runs."""

__tablename__ = "rotation_systems"

code: Mapped[str] = mapped_column(String(40), primary_key=True)
name: Mapped[str] = mapped_column(String(120), nullable=False)
# first_line | pivot | wide | front_line
family: Mapped[str] = mapped_column(String(20), nullable=False)
applies_to_formations: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
produces_shape: Mapped[str] = mapped_column(String(20), nullable=False)
trigger: Mapped[str] = mapped_column(Text, nullable=False)
what_moves_json: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
coaching_points_json: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
risk: Mapped[str] = mapped_column(Text, nullable=False)
requires_profile_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
animation_spec_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
exemplar_note: Mapped[str | None] = mapped_column(Text, nullable=True)
source_ref: Mapped[str | None] = mapped_column(String(60), nullable=True)
content_version: Mapped[str | None] = mapped_column(String(20), nullable=True)


class PositionArchetype(Base):
"""A named player profile within a slot family, e.g. an inverted
fullback (doc 06 section 3.1). `duties_json` is what the unit-balance
checker runs on (UnitBalanceRule); its vocabulary is closed and kept
small by doc 06's own instruction, so adding a duty is a spec change,
not a seed change."""

__tablename__ = "position_archetypes"

code: Mapped[str] = mapped_column(String(40), primary_key=True)
slot_family: Mapped[str] = mapped_column(String(20), nullable=False)
name: Mapped[str] = mapped_column(String(120), nullable=False)
definition: Mapped[str] = mapped_column(Text, nullable=False)
key_attribute_keys: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
# same_side | opposite_side | either, nullable per doc 06.
foot_hint: Mapped[str | None] = mapped_column(String(20), nullable=True)
awr_default: Mapped[str] = mapped_column(String(10), nullable=False)
dwr_default: Mapped[str] = mapped_column(String(10), nullable=False)
duties_json: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
enables_pattern_codes: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
enables_rotation_codes: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
needs_around_it: Mapped[str] = mapped_column(Text, nullable=False)
exemplar_note: Mapped[str | None] = mapped_column(Text, nullable=True)
source_ref: Mapped[str | None] = mapped_column(String(60), nullable=True)
content_version: Mapped[str | None] = mapped_column(String(20), nullable=True)


class ArchetypeCombination(Base):
"""A named pairing/trio of archetypes within one unit, e.g. a specific
double pivot combination (doc 06 section 3.1). `what_it_costs` is
required, not nullable, the same "state the cost" rule as
RotationSystem.risk."""

__tablename__ = "archetype_combinations"

code: Mapped[str] = mapped_column(String(40), primary_key=True)
# midfield_three | double_pivot | front_three | strike_pair |
# back_line | wide_unit | box_midfield
unit: Mapped[str] = mapped_column(String(30), nullable=False)
name: Mapped[str] = mapped_column(String(120), nullable=False)
slots_json: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
what_it_gives: Mapped[str] = mapped_column(Text, nullable=False)
what_it_costs: Mapped[str] = mapped_column(Text, nullable=False)
reference_note: Mapped[str | None] = mapped_column(Text, nullable=True)
home_formations: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
source_ref: Mapped[str | None] = mapped_column(String(60), nullable=True)
content_version: Mapped[str | None] = mapped_column(String(20), nullable=True)


class UnitBalanceRule(Base):
"""The generalisation of role_clashes (app/models/roster.py) across a
whole unit rather than a pair (doc 06 section 3.1): "reuse its
evaluation shape so the two engines read alike." `warning_copy` is
coach-facing and must read as a check, not an error, exactly like
RoleClash.warning_copy."""

__tablename__ = "unit_balance_rules"

code: Mapped[str] = mapped_column(String(40), primary_key=True)
unit: Mapped[str] = mapped_column(String(30), nullable=False)
# requires_duty | max_duty | max_same_archetype
rule_kind: Mapped[str] = mapped_column(String(20), nullable=False)
duty: Mapped[str | None] = mapped_column(String(20), nullable=True)
min_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
max_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
warning_copy: Mapped[str] = mapped_column(Text, nullable=False)
# note | warning
severity: Mapped[str] = mapped_column(String(10), nullable=False)
source_ref: Mapped[str | None] = mapped_column(String(60), nullable=True)
content_version: Mapped[str | None] = mapped_column(String(20), nullable=True)


class FormationMatchup(Base):
"""How one formation attacks another, "the how the ball finds it"
line (doc 06 section 3.1). Natural key (ours_code, theirs_code) with
ours_code <= theirs_code normalised at seed time (T-102/T-103), not
enforced as a DB constraint here, matching the repo's precedent of
keeping ordering/shape rules like this at the seed/validator layer
rather than as schema-level CHECK constraints."""

__tablename__ = "formation_matchups"

ours_code: Mapped[str] = mapped_column(ForeignKey("formations.code"), primary_key=True)
theirs_code: Mapped[str] = mapped_column(ForeignKey("formations.code"), primary_key=True)
our_edges_json: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
their_edges_json: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
route: Mapped[str] = mapped_column(Text, nullable=False)
# through | around | over
route_kind: Mapped[str] = mapped_column(String(10), nullable=False)
source_ref: Mapped[str | None] = mapped_column(String(60), nullable=True)
content_version: Mapped[str | None] = mapped_column(String(20), nullable=True)


# ---------------------------------------------------------------------------
# Team world (doc 06 section 3.2)
# ---------------------------------------------------------------------------


class TeamFormation(Base):
"""A coach's own saved formation setup for their team (doc 06 section
3.2). Team world, direct team_id, same scoping shape as SavedPattern
and Board (app/models/team_content.py).

active_phase_variant/opponent_phase_variant are plain vocabulary
strings, not a composite FK into formation_phases(formation_code,
variant_code): doc 06 does not ask for that referential integrity, and
T-101 seeds no formation_phases rows at all, so a composite FK here
would make every team_formation insert depend on content this ticket
deliberately leaves unseeded (T-102/T-103's job). Same reasoning as
FormationMatchup's ours_code<=theirs_code ordering: enforced at the
app/validator layer, not the schema, when that layer exists (T-108)."""

__tablename__ = "team_formations"

id: Mapped[int] = mapped_column(primary_key=True)
team_id: Mapped[int] = mapped_column(ForeignKey("teams.id"), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(120), nullable=False)
base_formation_code: Mapped[str] = mapped_column(ForeignKey("formations.code"), nullable=False)
active_phase_variant: Mapped[str] = mapped_column(String(30), nullable=False)
created_by_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utcnow, nullable=False
)
opponent_formation_code: Mapped[str | None] = mapped_column(
ForeignKey("formations.code"), nullable=True
)
opponent_phase_variant: Mapped[str | None] = mapped_column(String(30), nullable=True)


class TeamFormationSlot(Base):
"""One slot's assignment within a saved team formation (doc 06 section
3.2). Team world, but no team_id column of its own: scopes
transitively through team_formation_id -> team_formations.team_id,
exactly the pattern player_attributes uses through player_id ->
players.team_id (app/scoped.py TeamScope.query_via). Natural composite
key (team_formation_id, slot): one assignment per slot per saved
formation, same shape as session_receipts' (session_id,
player_user_id)."""

__tablename__ = "team_formation_slots"

team_formation_id: Mapped[int] = mapped_column(
ForeignKey("team_formations.id"), primary_key=True
)
slot: Mapped[str] = mapped_column(String(30), primary_key=True)
player_id: Mapped[int | None] = mapped_column(ForeignKey("players.id"), nullable=True)
archetype_code: Mapped[str | None] = mapped_column(
ForeignKey("position_archetypes.code"), nullable=True
)
# Coach-declared 1v1 advantage at this slot.
qualitative_edge: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
14 changes: 12 additions & 2 deletions backend/app/routers/formations.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,18 @@
# Bible 3G.2's rondo map order (first-line build-up through to the
# counterpress moment); only 433 carries seeded zones today (seeds/
# rondo_zones.json), but the ordering applies to any formation that gains
# a rondo map later.
ZONE_ORDER = ("first_line", "midfield_box", "flank_corridor", "last_line", "counterpress")
# a rondo map later. flank_corridor split into left/right (T-101,
# migration 0006, doc 06 section 3.1); left before right matches the
# repo's own slot-naming convention (formations.json lists every '_l'
# slot before its '_r' counterpart, e.g. fb_l before fb_r).
ZONE_ORDER = (
"first_line",
"midfield_box",
"flank_corridor_left",
"flank_corridor_right",
"last_line",
"counterpress",
)


def _order_index(value: str, order: tuple[str, ...]) -> int:
Expand Down
Loading
Loading