Skip to content

feat(tactics): Tactics Lab foundation, schema and superiority engine (T-100, T-101, T-104) - #18

Merged
BrandanBurgess merged 61 commits into
mainfrom
integration
Aug 7, 2026
Merged

feat(tactics): Tactics Lab foundation, schema and superiority engine (T-100, T-101, T-104)#18
BrandanBurgess merged 61 commits into
mainfrom
integration

Conversation

@BrandanBurgess

Copy link
Copy Markdown
Owner

First three tickets of Epic T-100 (Tactics Lab), per docs/source/06_Tactical_Depth_Spec.md. No user-visible surface yet: this is the scope amendment, the schema, and the pure engine. The Formations page rebuild lands in T-106 and T-107.

T-100, scope amendment (docs only)

Founder decision 2026-08-07. Four rows move from OUT to IN in Brief section 1:

  • Formation matchup overlay
  • Rondo Map overlay, now all six formations at six zones (flank corridor split left and right)
  • Auto footedness, split out of the F14-F16 row so F14-F16, crossing selectors and the Four-Run Box stay OUT
  • Full clash warning set, IN for unit-balance warnings only; Bible 2B.4's other clashes stay OUT

Doc 06 added to the CLAUDE.md source-of-truth table with its precedence rule.

T-101, schema and Alembic

Migration 0006, parent 0005. Single head.

New library-world tables (no team_id): formation_phases, rotation_systems, position_archetypes, archetype_combinations, unit_balance_rules, formation_matchups. Created empty; seed content is T-102 and T-103.

New team-world tables: team_formations (direct team_id), team_formation_slots (transitive through team_formation_id, the player_attributes pattern).

rondo_zones gains canonical_rondo, zone_kind, radius, and the single 4-3-3 flank_corridor row splits into flank_corridor_left and flank_corridor_right as a real data migration with a working downgrade, so an existing deploy upgrades cleanly. The side assignment is evidence-based: seeds/formations.json puts every _l slot at low y and every _r slot at high y, so the seeded y 75-100 polygon is factually the right corridor.

T-104, superiority engine

Pure, deterministic, framework-free, entirely inside frontend/src/board/. Tests written before implementation. 90 new tests.

mirrorOpponent, pointInPolygon, pointInCircle, countZone, findFreeMen, gridOccupancy, classifyRestDefence, buildRead with route inference. The JdP grid boundaries live in one exported constant.

Two deliberate and different boundary rules, documented at each point of enforcement: grid bands are a partition and use half-open lower-inclusive intervals so occupancy cannot double count; zone polygons count the boundary as inside because the six rondo zones are independent readings of the same pitch, not a partition.

Definition of done (doc 06 section 6)

  • make verify green on integration: lint, typecheck, pytest, vitest, e2e both viewports, em-dash scan, seed validator
  • No em dash in any seed file, blurb, warning or label
  • Every new team-world query goes through app/scoped.py; cross-team read test returns nothing for both new tables
  • Engine unit tests written before implementation, including mirror round-trip exactness and point-in-polygon boundary cases
  • Recompute benchmark measured, not assumed: median 0.018ms at 22 tokens and 6 zones against a 2ms budget
  • Coach-only 403 tests per route: T-108, in flight
  • Playwright journey for the new surface: T-109, once a surface exists

Note for the reviewer

playwright.config.ts sets reuseExistingServer and every worktree defaults to port 5173, so concurrent worktree verifies silently test another worktree's code and database. scripts/dev.sh already supports POP_WEB_PORT and POP_API_PORT for exactly this reason, but make verify never sets them. Worth wiring into the Makefile in T-109.

🤖 Generated with Claude Code

BrandanBurgess and others added 30 commits July 16, 2026 02:39
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…T-030)

Whiteboard page (Brief step 16, PNG 01-05/14/34): toolbar, view menu
(zones + thresholds), record/replay, and save into My Patterns, all
persisted through the scoped query layer (backend/app/routers/whiteboard.py)
into the boards and saved_patterns rows (doc 03 4.2/4.3). Delete is
coach-only, enforced server-side (403 for players), and every saved
pattern is author-stamped from the caller's own membership, never a
client-supplied field.

Fixes a real reload bug: the view menu's settings popover floated above
the toolbar and, left open, could visually sit on top of a token (e.g.
the ball's default spot, or any token near the toolbar on a phone-width
board). A drag or click landing there hit the popover instead of the
token, and for a checkbox's label that silently flipped a zone toggle
the user never touched, which then persisted to the boards row on
reload. Fixed by closing the view menu when recording starts (a
deliberate mode switch) and, for phone-width boards where the popover
has no gap in the pitch it can occupy without covering some token,
pinning it to the viewport's top edge instead of floating over the
pitch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Systematic permission test suite covering every Brief section 3 table
row in both roles, API and UI: backend/tests/test_permissions.py names
each test after its table row (whiteboard, delete-a-pattern, library/
formations/identity, roster/fit-warnings, plus the additive-only and
player-token-gets-403 principles), and e2e/permissions.spec.ts sweeps a
player-role visit across all five pages asserting coach-only controls
and data are absent from the DOM, not merely hidden.

Audit result: no enforcement gaps found. Whiteboard delete, roster CRUD,
and fit_warnings exclusion were already API-enforced from their own
tickets (T-030, T-033); this suite pins that behavior explicitly against
the table rather than leaving it implicit across five separate files.

Two table rows (Suggest own playstyle, Sessions) have no API surface yet
in this codebase state, only their models: marked as named, skipped
tests with a reason, for T-041/T-042 to turn into real assertions rather
than silently omitted.

One known ambiguity is pinned, not changed, per this ticket's own
instruction: the API returns join_code to any team member while the UI
shows it to coaches only (docs/agent/STATE.md open founder question 2).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Player submits a free-text playstyle suggestion on their own roster
profile, sees it pending; coach reviews from a team-wide queue with a
gold badge and an Approve/Dismiss card; approve merges the text into
players.playstyle_note (visible both roles), dismiss clears it with no
merge. New backend/app/routers/suggestions.py enforces every direction
at the API: only a player may submit, and only against their own linked
roster row; only a coach may list the pending queue, approve, or
dismiss (403 otherwise). No new migration: playstyle_suggestions already
exists in the 0002 schema migration.

Player-row linkage (needed for "own profile") did not exist yet before
this ticket; app/routers/roster.py adds a minimal, UI-free claim: on a
player's own GET /api/roster, an unclaimed row whose name uniquely
matches their display_name becomes theirs.
BrandanBurgess and others added 28 commits July 16, 2026 13:11
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-012)

Founder decision 2026-07-16: doc 03's identities schema had no age_hint
when T-010 shipped, so Bible 8.2.4's age-suitability rule only covered
library_items. Adds migration 0004 (down_revision 0003), the age_hint
column on Identity/IdentityOut, backfills all 27 identity cards from the
Bible, makes age_hint required in the seed validator, and renders it on
the Identity page's details panel.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…042)

The coach-to-player loop (Brief step 23, PNG 21-23, 26, 28).

Backend (app/routers/sessions.py, app/schemas.py):
- Draft builder: create, edit title/note, attach library presets and the
  team's own recordings, reorder, remove. Draft-only: a sent session is a
  record of what the team was actually told, so edits 409.
- Send writes a receipt for every player-role member at send time with
  viewed_at null (doc 03 section 6); Mark as watched (player-only) sets it
  once and keeps the first watch time.
- Coach/player payload split follows RosterOut/CoachRosterOut exactly:
  response_model=None plus a manual model_dump, so receipts/viewed_count/
  recipient_count are ABSENT from a player body and you_watched is absent
  from a coach body. Every route scopes through TeamScope; session_items
  and session_receipts read through query_via, their parent's team_id.

Frontend (pages/SessionsPage.tsx, sessionsApi.ts, AppShell nav entry):
- Coach: session rail, draft builder with the "+ Add from library" picker
  over presets and My patterns (mini-board thumbnails reuse TileThumb),
  coach note, gold SENT pill with the x/y viewed counter, per-player
  receipts (Will receive / Viewed / Not yet).
- Player: sent sessions only, read-only, Watch opens the item playing on
  PatternPreviewBoard (portrait on phone), plus Mark as watched.
- The wire types make the split structural: isCoachSession() narrowing,
  no nullable receipt field a player view could ever render.

Tests: backend/tests/test_sessions_routes.py (15 cases: item kinds,
reorder, send validation, cross-team scoping, receipt contract, jersey
badges), e2e/sessions.spec.ts on both viewports, and the two remaining
@pytest.mark.skip placeholder rows in test_permissions.py ("suggest own
playstyle", "sessions") replaced with real assertions. The permission
suite now has zero skips.

make verify green (exit 0).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One command before a meeting: drop the dev database, rebuild it from the
migration chain, load the library content, then populate one realistic
team so the app is never empty when a coach opens it.

Seeded state:
- Coach and player accounts with fixed credentials (printed to stdout),
  a team with fixed join codes, both memberships.
- 14 roster players with roles, flanks, work rates and all six sliders,
  including exactly one double-exposure flank pair (Jordan Tavares, right
  wing, AWR high DWR low, in front of Marco Silva, right back, AWR high),
  so the coach-only warning fires for one reason on one flank.
- The team's live whiteboard: the 4-3-3 shape with two confirmed lanes.
- A recorded pattern, "Our build-out vs press": eight tokens moving over
  five seconds (centre-back steps out, pivot drops, fullback goes high,
  the eight arrives inside, the winger spins in behind, two opponents
  pressing, the ball chasing whoever has it), not a stub.
- One sent session with two items (library A5 plus that recording) and a
  receipt already watched, so the counter reads 1 of 1.

Idempotent by natural key like the content seeder, so it is safe to rerun.
check_copy.py now also scans scripts/ and README.md, since the demo seed
ships real user-facing copy; the two files that must name the em dash to
search for it are exempted explicitly.

make verify green (exit 0).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…T-050)

Verified every page at iPhone 13 (390x844) and fixed what the pass found.

Real gaps closed:
- `.ctl-ghost` was defined only as `.save-bar .ctl-ghost` and
  `.saved-pattern .ctl-ghost` in Board.css, so every later reuse of the
  class (Formations "Rondo map", the Patterns meta bar's Open on
  whiteboard and Clear, the new Sessions controls) matched nothing and
  fell back to the browser's default grey button. Promoted to a real
  shared neutral button in global.css; the scoped Board.css rules keep
  winning inside the save bar on specificity.
- The four auth screens (register, log in, create team, join by code) had
  no styling at all: labels ran into their inputs, buttons were browser
  chrome. These are the first two screens of the demo narrative and Brief
  section 8 asks for exactly this ("smallest functional version using the
  token system and existing component patterns"). New auth.css gives them
  the same card/pill/gold-primary idiom, with no DOM change, so every
  getByLabel and testid the platform journeys use still resolves.
- The phone topbar ate a fifth of the screen before the board started:
  tighter padding, join codes side by side, smaller type. Nothing hidden.
  The Manage team panel is pinned to the viewport so it cannot overhang.
- TileThumb dots shrink past 12 tokens, so a saved 23-token recording
  reads as a shape rather than confetti.

New coverage:
- e2e/phone.spec.ts (mobile project only): the 52px icon rail, all six
  pages portrait with no horizontal overflow, and every sheet, the board
  view menu, and the Manage team panel opening AND closing by tap().
- e2e/cross-device.spec.ts: a coach records on a 1440x900 browser and the
  same coach, signed in on a separate iPhone 13 browser, replays it from
  the server. Asserts both halves of the contract: identical stored model
  coordinates, and rendering through the portrait mapping.
- fixtures.ts gains signIn() for second-device journeys.

make verify green (exit 0), 96 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tcher (T-051)

Hardening, scoped to the demo path.

- e2e/demo-path.spec.ts runs the Brief section 6 acceptance narrative as
  one journey under BOTH Playwright projects: coach signs up and creates a
  team, adds six players with roles and sliders (the pair that fires the
  double-exposure warning among them), loads 4-3-3 and taps the pivot
  keystone, toggles the Rondo Map and taps the first-line zone, searches
  "third man" and plays A5, drags and records a build-out and saves it as
  "Our build-out vs press", builds a session with A5 plus the recording
  and a note and sends it, then the player (always in a 390x844 context,
  per "switches to a player account on a phone") opens it, watches A5
  portrait, marks it watched, and the coach's counter reads 1 of 1.

- `make permissions` runs the Brief section 3 table and fails if any row
  is SKIPPED rather than asserted, not just if one fails. Wired into
  `make verify` and into CI as its own named step. Two rows sat as skip
  placeholders for most of this build; a green suite that quietly stops
  checking a permission row is the failure mode worth pinning.

- Em-dash sweep is clean repo-wide: the only remaining occurrences are
  docs/source (raw product docs, allowed), the two scripts that must name
  the character to search for it, and three tests asserting its absence.

- pages/search.ts replaces four identical copies of the search matcher and
  makes matching hyphen-insensitive. The content is full of hyphenated
  names ("Third-Man Run", "Slide-Rule Through Ball") and a coach types
  what they say out loud, so the Brief's own narrative (searches "third
  man") previously found nothing. Hyphenated queries still match.

make verify green (exit 0), 98 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 5: the shop window, plus the polish gaps capturing it exposed.

e2e/screenshots.spec.ts drives the real UI against the `make demo` database
and writes eleven PNGs to docs/screenshots/. It is a capture script, not a
test: it skips unless POP_SCREENSHOTS=1, so `make verify` never runs it or
rewrites the images. `make screenshots` reseeds the demo data first, so the
captures always show the same content. Animated surfaces are caught mid
motion (a pass in flight, a keystone at full glow, a drag still in hand),
and it uses the pitch theme rather than dark: every shot is a football
board, and the flat grey dark theme reads as a generic dashboard.

Board and screen gaps found while checking each shot against its PNG:

- The ball's gold trace WHILE RECORDING (design README, PNG 03) was never
  implemented. Only playback drew a trail; a coach recording a move saw
  nothing behind the ball. Now drawn by the same AnimationOverlay and the
  same trail buffer the player uses, so a trace being recorded and one
  being replayed are one piece of code. Covered by a new recorder journey.
- The swipe-up sheets pushed the page taller instead of overlaying it as a
  bottom drawer (PNG 07, 38, 40), so opening the library scrolled the
  header and the top of the board off screen. All three sheets are now real
  fixed bottom drawers that scroll internally.
- Keystone tokens painted Chromium's default blue focus ring on the pitch,
  on a product where gold is the only interactive colour. Pointer focus
  drops the ring; keyboard focus gets a gold one.
- The rondo zone card was clipped by the sheet handle; it now sits above it
  with its own max height.
- Confirmed lanes in the demo seed were placed where the mirrored opponent
  shape blocks them, so the whiteboard opened entirely red. Moved to two
  lines that are clear in the default shape.

Demo seed also gains four more player accounts and a second, DRAFT session,
so the receipts view reads like a squad (3 of 5 viewed, some Not yet) and a
coach has something to send live in the room.

README.md: hook, hero shot, four coach-language bullets, the eleven
screenshots in demo-narrative order with a caption each, quickstart with the
demo credentials, and a stack and architecture section at the bottom.

BACKLOG and STATE updated: T-040, T-042, T-050, T-051 done, with the
decisions taken and the gaps closed recorded for the next session.

make verify green (exit 0), 100 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rint (T-060)

The repo had no deployment configuration at all; this adds it.

- backend/app/main.py serves the built SPA alongside the API when the build
  output is present. One origin is what makes the existing session cookie
  work as written: it is SameSite=Lax and host-only, and api.ts fetches
  relative /api paths with no CORS setup and no credentials mode. A second
  origin would mean SameSite=None, an allow-list, and a flag on every fetch.
  The block is conditional on frontend/dist existing, so a dev checkout and
  the test suite are unchanged (API only, no catch-all registered).
  /api/* that matches nothing stays a JSON 404 rather than falling through
  to the HTML shell, and path traversal resolves outside the build output
  and falls back to index.html instead of being served.

- Dockerfile: two stages, node builds the SPA (via `npm run build`, which
  typechecks first, so a type error fails the image), python runs it. No
  node and no dev extras in the runtime image.

- scripts/start.sh mirrors dev.sh's boot order (migrate, seed, serve) so a
  deployed instance and a dev checkout cannot drift. It refuses to boot when
  COOKIE_SECURE=true and JWT_SECRET is unset, because the fallback secret in
  app/config.py is in the repository and would let anyone forge a session.

- render.yaml: one free Docker web service, health check on /api/health,
  generated JWT_SECRET, POP_SEED_DEMO=true.

Storage is deliberately ephemeral on the free plan and the blueprint says so
in full: no disk, so the database is rebuilt on every boot, which is why the
demo seed runs at start and the README credentials always work. Raising the
plan and attaching a disk at /data is the one change needed to make it
durable; DATABASE_URL already points there.

Verified locally: image builds, container boots, SPA and hashed assets
serve, deep links fall back, unknown API paths 404 as JSON, both demo
accounts log in and read their seeded data, and the JWT_SECRET gate fires.

make verify green (exit 0), 100 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`make verify` was green locally and failed in CI with 140 ruff errors in
files this branch never touched. Cause: `ruff>=0.5` and `mypy>=1.10` are
open-ended, so CI resolved whatever had shipped that morning while local
kept what it installed months ago. A ruff minor release expanded its
default rule set (UP007, RUF100, PLW1510 among them) and the build broke on
migrations and test files that had not changed.

Pinned to the minor: patch fixes still flow, but a rule-set change becomes a
deliberate bump someone reviews rather than a surprise on an unrelated PR.
Verified by resolving the pins in a clean venv the way CI does: ruff 0.15.22,
mypy 2.3.0, pytest 9.1.1, both lint and typecheck clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Points at the deployed instance and states the free-plan tradeoff plainly:
no persistent disk, so the database and the demo team are rebuilt on every
boot (which is what keeps the published credentials working), and a free
instance sleeps after about fifteen minutes. Records the three changes that
make it durable for a real club.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds docs/source/06_Tactical_Depth_Spec.md as the source of truth for the
T-100 epic and the ten ticket rows to docs/agent/BACKLOG.md.

Founder decision 2026-08-07: doc 06 Section 0's four scope moves are
approved (formation matchup overlay, rondo map on all six formations at
six zones, auto footedness, unit-balance warnings). T-100 amends the
Brief section 1 table itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Move formation matchup overlay, auto footedness, and unit-balance
clash warnings from OUT to IN, expand Rondo Map overlay to all six
formations and six zones, and register doc 06 as the source-of-truth
for the T-100 epic per the founder's 2026-08-07 decision.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Doc 06 sections 2.2 and 4. Pure, deterministic, framework free. Landscape
model coords throughout (CLAUDE.md rule 8); orientation appears nowhere in
the engine.

Written tests first, in this order:
  1. grid.test.ts and superiority.test.ts, with the mirror involutivity
     property test at the top of the file because it was first in the
     working order: no mirror code existed when it was written.
  2. superiority.bench.test.ts.
  3. Then the implementation. The red run failed all three files on
     unresolved imports, then went 90/90 green on the first pass.
Committed as one commit because CLAUDE.md rule 2 forbids committing a red
tree, so the intermediate red state cannot be a commit of its own.

What landed:
- grid.ts: JDP_GRID holds the section 2.2 lane and line boundaries in ONE
  place, with the three occupancy limits. Bands are half-open and lower
  inclusive, [min, max), final band closed, applied identically to lanes and
  lines: y exactly 19 is the left half-space, x exactly 100 is the last line.
  gridOccupancy reports breaches and never blocks.
- geometry.ts: pointInPolygon and pointInCircle added next to the existing
  segment maths rather than starting a second geometry module. Boundary
  counts as inside; on-boundary is settled before the ray is cast, and the
  half-open crossing test handles a ray through a vertex.
- superiority.ts: mirrorOpponent, countZone, findFreeMen,
  classifyRestDefence, buildRead and route inference.
- superiorityTypes.ts: types only, so grid and superiority share them with
  no import cycle. Seeded zones and matchup cards are INPUTS, so the engine
  is testable before T-101 and T-103 land.

Benchmark: 22 tokens, 6 zones, full recompute. 300 warmup runs then 501
timed runs, asserting on the median (not the mean, not the max) so CI load
cannot flake it, plus a loose p99 ceiling to catch a real complexity
regression. Measured median 0.0148ms against the 2ms budget.

make verify green, exit 0, including all 100 e2e at both viewports.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds the six new library-world tables and two team-world tables doc 06
section 3 specifies for the Tactics Lab epic (formation_phases,
rotation_systems, position_archetypes, archetype_combinations,
unit_balance_rules, formation_matchups, team_formations,
team_formation_slots), all empty: seed content is T-102/T-103's job.

rondo_zones gains canonical_rondo, zone_kind, and radius columns, plus a
data migration that splits the single seeded 4-3-3 flank_corridor row
into flank_corridor_left and flank_corridor_right. Cross-referencing
seeds/formations.json's own slot convention (every '_l' slot sits at low
y, every '_r' slot at high y) shows the existing polygon (y 75-100) is
the right flank, kept unchanged as flank_corridor_right; the left
counterpart is its mirror across y=50. Existing consumers (the
formations router's zone ordering, its route test, the seed content
count test, and one e2e assertion) are updated to match; no product
surface is added.

team_formations scopes directly by team_id; team_formation_slots scopes
transitively through team_formation_id, the same shape player_attributes
already uses, via the existing generic TeamScope.query()/query_via()
(no new scoped.py methods needed). Cross-team read tests for both tables
return nothing, per the Platform DoD.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@BrandanBurgess
BrandanBurgess merged commit 093698f into main Aug 7, 2026
1 check passed
BrandanBurgess added a commit that referenced this pull request Aug 9, 2026
PR #18 squashed T-100, T-101 and T-104 onto main as one commit, which is not in integration's history, so git saw a conflict on every file those tickets touched that later tickets also changed. Integration is strictly ahead: main's only unique commit is that squash, and its content is already here. Resolved every conflict to integration's side; the resulting tree is identical to integration before the merge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant