v1.15.0 — Roster discovery + classification (core / substitute / sub / ignore)
The existing Members tab was form-driven: type a name, then type each character + server by hand. For a static with sub-accounts + occasional substitutes + pugs that appear once i…
Why
The existing Members tab was form-driven: type a name, then type each character + server by hand. For a static with sub-accounts + occasional substitutes + pugs that appear once in a leftover report, this leaves the human doing reconciliation work the database could do — we already KNOW every character that has appeared in any watched report; just show them and ask the user to classify.
Added — schema (alembic b5de1c10f4af_roster_kind_ignored_characters)
members.kind— Text NOT NULL, server-default'core'. Existing rows backfill to'core'. Allowed values:'core'|'substitute'. Both kinds count toward analytics identically today; the tag is for the human's roster view. Substitutes show in their own section in the UI to keep the regular roster clean.ignored_characters— static-scoped table persisting the "this combatant is not part of our static" decision. Unique partial index on(static_id, character_name, COALESCE(server, ''))so the same ignore is durable across re-ingests and one (name, server) pair per static yields at most one row. FK cascades on static delete.
Added — analysis/roster_discovery.py::discovered_characters_for_static()
- One server-side aggregation
combatants → fights → watched_reportsfiltered onstatic_idyields distinct(character_name, server)rows withfights_seen(distinct fight count) andlatest_job(job from the most recent fight). Player-with-NULL-name rows are excluded. - Each row classified as one of:
core— character is an alias of aMemberwithkind='core'and that's the member's only alias.substitute— same as core but member kind issubstitute.sub— character is an alias of a member who owns ≥2 aliases (so it's plausibly an alt). UI hint only; structurally still just an alias.ignored— listed inignored_charactersfor this static.unclassified— no alias or ignore row claims it.
- Alias lookup mirrors
resolve_members.py: prefer(name, server)exact match; fall back to name-only iff exactly one member claims it. - Sorted by
fights_seendesc, tiebroken by name. Most-recurrent characters surface first (real members beat pugs).
Added — /api/roster/characters (GET) and /api/roster/classify (POST)
GET /api/roster/characters— context-scoped, returns{static_id, characters: [...]}.POST /api/roster/classify— single-character bulk router. Body{character_name, server, action, member_id?, member_name?}where action is one ofcore/substitute/sub/ignore/clear:core/substitute: ifmember_idprovided, attach this character as a new alias on that member; else create a new Member (name =member_nameorcharacter_name) with the requested kind + attach the alias.sub: requiresmember_id; attaches this character as an alias of that member (used for sub-accounts of an existing core member).ignore: adds anignored_charactersrow (idempotent); removes any existing alias for this (name, server).clear: removes any alias and any ignore row → returns the character tounclassified.
- Every action wipes prior alias + prior ignore state for the same (name, server) so the transitions are clean (no orphan rows, no ambiguous double-classification).
Changed — /api/members
POST /api/membersandPATCH /api/members/{id}now accept akindfield (validates against('core', 'substitute'), returns 422 on invalid).POSTdefaults to'core'.MemberOutcarrieskind.
React — web/src/Members.jsx rewritten
Three sections:
- Core members — one card per
kind='core'member with their attached aliases as pills (× to detach), a dropdown that lists the static'sunclassifiedcharacters with their server + latest job + pull count for one-click sub-account attach, and inlinemark substitute/promote to core/deletebuttons. - Substitutes — same UI, only rendered when ≥1 substitute member exists.
- Characters seen in reports — filter chips (
all/unclassified/hide ignored/ignored) + search box + per-row classification table with columnsCharacter · Server · Latest job · Pulls · Status · Actions. Action buttons per row:core,substitute,sub of <member>…dropdown + attach,ignore,clear. Status pills color-coded (accent for core, warning for substitute, danger for ignored, dim for unclassified). A+ Add member manuallycard sits between sections 2 and 3 for the rare case where you want to create a member before any of their characters appear in logs.
Tests
- 13 new in tests/test_roster_discovery_v1_15.py: Members.kind round-trip (default core, substitute via POST, PATCH validation, invalid 422 on create); discovery (distinct (name, server) listing, latest_job picks freshest fight, ranks by fights_seen); classification flows (core attaches + classifies, sub when owner has >1 alias, ignore + clear, switching action wipes prior state, sub-without-member-id 422, invalid action 422); cross-static isolation; ignore idempotency.
- 493 tests passing (480 → 493, +13). Full suite green.
Live AC against Default Static's DSR data
/api/roster/charactersreturns 20 distinct characters, ranked byfights_seen: real DSR static members at 450+ pulls each, FFLogs pseudo-actor "Multiple Players" at 471 (visible in the list; oneignoreclick hides it permanently).- Classified "Ayato Polaali" as core via the new API: auto-created Member (id=2101, kind="core") with the character as its sole alias. Next discovery call shows the row as
classification: "core",linked_member_name: "Ayato".
Known minor wart (not blocking)
FFLogs pseudo-actors like "Multiple Players" and "Limit Break" appear in the discovery list since the underlying combatants table doesn't filter them. One-click ignore makes the decision durable. Server-side filtering can be added later (resolve_members.py already has the name list); left out so the UI stays "show every character that appeared" rather than "show every character we think is a player".