Vigil
← All news

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_reports filtered on static_id yields distinct (character_name, server) rows with fights_seen (distinct fight count) and latest_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 a Member with kind='core' and that's the member's only alias.
    • substitute — same as core but member kind is substitute.
    • 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 in ignored_characters for 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_seen desc, 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 of core / substitute / sub / ignore / clear:
    • core / substitute: if member_id provided, attach this character as a new alias on that member; else create a new Member (name = member_name or character_name) with the requested kind + attach the alias.
    • sub: requires member_id; attaches this character as an alias of that member (used for sub-accounts of an existing core member).
    • ignore: adds an ignored_characters row (idempotent); removes any existing alias for this (name, server).
    • clear: removes any alias and any ignore row → returns the character to unclassified.
  • 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/members and PATCH /api/members/{id} now accept a kind field (validates against ('core', 'substitute'), returns 422 on invalid). POST defaults to 'core'. MemberOut carries kind.

React — web/src/Members.jsx rewritten

Three sections:

  1. Core members — one card per kind='core' member with their attached aliases as pills (× to detach), a dropdown that lists the static's unclassified characters with their server + latest job + pull count for one-click sub-account attach, and inline mark substitute / promote to core / delete buttons.
  2. Substitutes — same UI, only rendered when ≥1 substitute member exists.
  3. Characters seen in reports — filter chips (all / unclassified / hide ignored / ignored) + search box + per-row classification table with columns Character · 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 manually card 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/characters returns 20 distinct characters, ranked by fights_seen: real DSR static members at 450+ pulls each, FFLogs pseudo-actor "Multiple Players" at 471 (visible in the list; one ignore click 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".