feat(tactics): Tactics Lab foundation, schema and superiority engine (T-100, T-101, T-104) - #18
Merged
Conversation
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>
…, dev.sh overlap with T-031)
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.
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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
Doc 06 added to the CLAUDE.md source-of-truth table with its precedence rule.
T-101, schema and Alembic
Migration
0006, parent0005. 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(directteam_id),team_formation_slots(transitive throughteam_formation_id, theplayer_attributespattern).rondo_zonesgainscanonical_rondo,zone_kind,radius, and the single 4-3-3flank_corridorrow splits intoflank_corridor_leftandflank_corridor_rightas a real data migration with a working downgrade, so an existing deploy upgrades cleanly. The side assignment is evidence-based:seeds/formations.jsonputs every_lslot at low y and every_rslot 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,buildReadwith 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 verifygreen on integration: lint, typecheck, pytest, vitest, e2e both viewports, em-dash scan, seed validatorapp/scoped.py; cross-team read test returns nothing for both new tablesNote for the reviewer
playwright.config.tssetsreuseExistingServerand every worktree defaults to port 5173, so concurrent worktree verifies silently test another worktree's code and database.scripts/dev.shalready supportsPOP_WEB_PORTandPOP_API_PORTfor exactly this reason, butmake verifynever sets them. Worth wiring into the Makefile in T-109.🤖 Generated with Claude Code