/* ============================================================
   AppState — useReducer-based state, with derived computations
   ============================================================ */
const { useState, useEffect, useReducer, useMemo, useRef, useCallback } = React;

/* Decisions schema:
   decisions[playerName] = { kind, salary?, years? }
   additions[id]    = { id, name, salary, years, source, position }
   draftPick.disposition = "keep" | "trade-pick" | "sign-and-trade"
*/

/* Step-1 "Options" decisions (opt-in/out, exercise/decline, keep/waive) are
   SHARED across Cap/Apron; everything else (FA holds/signings, additions, draft
   dispositions, trades) is split per-mode. */
const OPTION_KINDS = new Set(["opt-in", "opt-out", "exercise", "decline", "keep", "waive"]);
const emptyBucket = () => ({ decisions: {}, additions: [], draftPicks: [], tradedAway: [] });
const emptyScenario = () => ({ options: {}, rosters: {}, appliedTrades: [], draftTrade: null, hardCaps: {}, createdTpes: {} });

/* one-world #14, Phase 4: the in-progress Trade Machine deal lives in the active
   scenario as draftTrade = { slots:[…] }, stored as PLAIN OBJECTS so it
   serializes into capmvp-state with the rest of the scenario (no Map replacer
   needed, no second localStorage store). The TM renders Map-shaped slots, so we
   convert at the boundary (slotObjToMap on read, slotMapToObj on write). */
const emptyDraftSlot = (code) => ({ code, players: {}, picks: {}, fas: {}, tpes: {}, cash: { out: 0, dest: null } });
function slotObjToMap(o) {
  if (!o || typeof o !== "object") return o;
  const m = (x) => new Map(Object.entries(x || {}));
  return { ...o, players: m(o.players), picks: m(o.picks), fas: m(o.fas), tpes: m(o.tpes), cash: o.cash || { out: 0, dest: null } };
}
function slotMapToObj(s) {
  if (!s || typeof s !== "object") return s;
  const o = (x) => (x instanceof Map) ? Object.fromEntries(x) : (x && typeof x === "object" ? x : {});
  return { ...s, players: o(s.players), picks: o(s.picks), fas: o(s.fas), tpes: o(s.tpes), cash: s.cash || { out: 0, dest: null } };
}

/* ── Hard-cap persistence (Phase 4d) ──────────────────────────────────────
   scenario.hardCaps[team] = [ { at:"firstApron"|"secondApron", source, tradeId?, signId? } ]
   — one entry per imposing source (a trade side, or an NT-MLE/BAE/TP-MLE signing)
   so UNDO_TRADE / REMOVE_ADDITION can drop it precisely. The STRICTEST cap (the
   LOWER-dollar firstApron beats secondApron) governs; project to the salary-bar
   string ("apron1"/"apron2"). state.hardCap (active team) is set by recompute. */
const HARDCAP_RANK = { firstApron: 2, secondApron: 1 };
function strictestHardCap(entries) {
  let best = null, bestRank = 0;
  for (const e of (entries || [])) {
    const r = HARDCAP_RANK[e && e.at] || 0;
    if (r > bestRank) { bestRank = r; best = e.at; }
  }
  return best;
}
function hardCapBarString(at) { return at === "firstApron" ? "apron1" : at === "secondApron" ? "apron2" : null; }
/* strictest of a raw apron-name array (e.g. one trade side's side.hardCaps) */
function strictestOf(list) { return strictestHardCap((list || []).map(at => ({ at }))); }

/* R12 scenarios layer (2026-05-27).
   ───────────────────────────────────────────────────────────────
   Source-of-truth for options/rosters/appliedTrades lives inside
     state.scenarios[state.activeScenario]
   Top-level state.options / state.rosters / state.appliedTrades are
   MIRRORS rebuilt by recompute() on every action so existing callers
   (app.jsx seed effect, etc.) keep reading the same shape.
   New actions (APPLY_TRADE / UNDO_TRADE / scenario ops) write to the
   scenarios layer directly; existing actions go through the same path
   via `getOpts(s)` / `setOpts(s, patch)` / `getRosters(s)` /
   `setRosters(s, patch)` helpers so the reducer reads cleanly. */

const initialState = {
  team: "LAL",
  mode: "apron",                // 'apron' | 'cap' — the CURRENT team's strategy
  modeByTeam: {},               // per-team strategy memory: { code: 'apron'|'cap' }
  modeConfirmed: {},            // teams that confirmed their Over/Cap-space pick (collapses the decision row → chip)
  capForAllTeams: false,        // gear master: offer "Use cap space" for every team (else only the curated 5)
  multiYear: true,              // Chunk 2: multi-year always on (years shown inline; no gear toggle)
  adjustRaises: true,           // [tweak f] always on (the gear toggle was removed; multiYear is always on)
  // R12: scenarios — each carries its own options/rosters/appliedTrades.
  scenarios: { default: emptyScenario() },
  activeScenario: "default",
  // R14.A: drag-to-reorder of Trade Machine roster rows (Fanspo-style).
  // Purely visual — does not affect cap math. Keyed by team code; the array
  // is a list of player names in the user's preferred display order. Players
  // not in this list fall through to the default salary-desc sort below them.
  tmRowOrders: {},
  // MIRRORS of active scenario (recompute writes — do NOT dispatch into):
  options: {},
  rosters: {},
  appliedTrades: [],
  // Derived "active" view (rebuilt by recompute) — what every consumer reads.
  decisions: {},
  additions: [],
  draftPicks: [],
  tradedAway: [],
  toast: null,
};

/* Scenario accessors — read source of truth, return safe defaults. */
function curSc(s) { return s.scenarios[s.activeScenario] || emptyScenario(); }
function getOpts(s)    { return curSc(s).options; }
function getRosters(s) { return curSc(s).rosters; }
/* Patch the active scenario immutably and re-mirror via recompute. */
function patchScenario(s, patch) {
  const sc = curSc(s);
  return { ...s, scenarios: { ...s.scenarios, [s.activeScenario]: { ...sc, ...patch } } };
}
function setOpts(s, newOptions)    { return patchScenario(s, { options: newOptions }); }
function setRostersMap(s, newRosters) { return patchScenario(s, { rosters: newRosters }); }

// Active view = shared options merged with the current team/mode bucket (the
// per-mode FA decision overrides the shared option for that player).
function recompute(s) {
  const sc = curSc(s);
  const opt = sc.options[s.team] || {};
  const b = (sc.rosters[s.team] && sc.rosters[s.team][s.mode]) || emptyBucket();
  // Phase 4d: project the active team's strictest stored hard cap → the bar string.
  const hardCap = hardCapBarString(strictestHardCap((sc.hardCaps && sc.hardCaps[s.team]) || []));
  return { ...s,
    options: sc.options, rosters: sc.rosters, appliedTrades: sc.appliedTrades,
    hardCap,
    decisions: { ...opt, ...b.decisions },
    additions: b.additions, draftPicks: b.draftPicks, tradedAway: b.tradedAway };
}
function curBucket(s) {
  const rosters = getRosters(s);
  return (rosters[s.team] && rosters[s.team][s.mode]) || emptyBucket();
}
/* Resolve ANY team's view from the active scenario (one-world #14, Phase 2):
   the same options-merged-with-mode-bucket that recompute() produces for the
   active team, but for an arbitrary `code` using that team's own remembered
   mode. For the active team this returns exactly state.{decisions,additions,
   draftPicks,mode}, so App's derivedByTeam[state.team] === the active `derived`. */
function selectTeamView(s, code) {
  const sc = curSc(s);
  const mode = (s.modeByTeam && s.modeByTeam[code]) || "apron";
  const opt = (sc.options && sc.options[code]) || {};
  const b = (sc.rosters && sc.rosters[code] && sc.rosters[code][mode]) || emptyBucket();
  return { decisions: { ...opt, ...b.decisions }, additions: b.additions || [], draftPicks: b.draftPicks || [], mode };
}
// Immutably patch the current team/mode roster bucket (active scenario).
function setRoster(s, patch) {
  const rosters = getRosters(s);
  const team = rosters[s.team] || {};
  const cur = team[s.mode] || emptyBucket();
  return setRostersMap(s, { ...rosters, [s.team]: { ...team, [s.mode]: { ...cur, ...patch } } });
}
/* Variant: patch a SPECIFIC team's mode-bucket (used by APPLY_TRADE for
   non-managed teams). Mirrors setRoster but for an arbitrary {team,mode}. */
function setTeamModeBucket(s, team, mode, patch) {
  const rosters = getRosters(s);
  const t = rosters[team] || {};
  const cur = t[mode] || emptyBucket();
  return setRostersMap(s, { ...rosters, [team]: { ...t, [mode]: { ...cur, ...patch } } });
}

/* Reverse ONE applied trade's roster effects across all its slots (both mode
   buckets), returning the (mutated) rosters map. Tag-based, so it's
   order-independent — safe to undo any single trade, not just the most recent
   (the re-use guard keeps trades from chaining). Crucially it RESTORES each
   traded player's pre-trade decision (captured as `prev` by APPLY_TRADE) rather
   than dropping it, so a cap-mode edit made before the trade (a re-sign, opt-in,
   …) survives the undo. Shared by UNDO_TRADE and RESET_TEAM. */
function reverseTradeInRosters(rosters, trade) {
  const MODES = ["apron", "cap"];
  for (const sl of (trade.slots || [])) {
    const teamRoot = { ...(rosters[sl.code] || {}) };
    for (const mode of MODES) {
      const cur = { ...emptyBucket(), ...(teamRoot[mode] || {}) };
      const decisions = { ...(cur.decisions || {}) };
      for (const k of Object.keys(decisions)) {
        if (decisions[k] && decisions[k].tradeId === trade.id) {
          const prev = decisions[k].prev;
          if (prev === undefined) delete decisions[k];     // no prior decision → back to default
          else decisions[k] = prev;                         // restore the pre-trade decision
        }
      }
      const additions = (cur.additions || []).filter(a => a._fromTrade !== trade.id);
      let draftPicks = (cur.draftPicks || []).filter(p => p._fromTrade !== trade.id);
      if (Array.isArray(sl._removedPicks)) draftPicks = [...draftPicks, ...sl._removedPicks];
      teamRoot[mode] = { ...cur, decisions, additions, draftPicks };
    }
    rosters[sl.code] = teamRoot;
  }
  return rosters;
}

function appReducer(state, action) {
  switch (action.type) {
    case "SET_MODE":     return recompute({ ...state, mode: action.mode, modeByTeam: { ...state.modeByTeam, [state.team]: action.mode } });
    // Adjust raises only makes sense with multi-year on — clear it when off.
    case "SET_MULTI_YEAR":    return { ...state, multiYear: action.on, adjustRaises: action.on ? state.adjustRaises : false };
    case "SET_ADJUST_RAISES": return { ...state, adjustRaises: action.on };
    case "SET_CAP_FOR_ALL":   return { ...state, capForAllTeams: action.on };
    // Confirm the Over/Cap-space pick for the current team (collapses the big
    // decision row down to the chip). Pairs with SET_MODE (which sets the pick).
    case "CONFIRM_MODE":      return { ...state, modeConfirmed: { ...state.modeConfirmed, [state.team]: true } };
    // Switching teams restores THAT team's saved strategy (per-team, not global).
    case "SET_TEAM":     return recompute({ ...state, team: action.team, mode: state.modeByTeam[action.team] || "apron" });
    case "SET_DECISION": {
      const { player, decision } = action;
      let s = state;
      if (decision == null) {
        // clear the player from BOTH layers
        const opts = getOpts(s);
        if (opts[s.team] && opts[s.team][player] !== undefined) {
          const opt = { ...opts[s.team] }; delete opt[player];
          s = setOpts(s, { ...opts, [s.team]: opt });
        }
        if (curBucket(s).decisions[player] !== undefined) {
          const d = { ...curBucket(s).decisions }; delete d[player];
          s = setRoster(s, { decisions: d });
        }
      } else if (OPTION_KINDS.has(decision.kind)) {
        // shared option decision; clear this mode's FA override so it applies now
        const opts = getOpts(s);
        const opt = { ...(opts[s.team] || {}), [player]: decision };
        s = setOpts(s, { ...opts, [s.team]: opt });
        if (curBucket(s).decisions[player] !== undefined) {
          const d = { ...curBucket(s).decisions }; delete d[player];
          s = setRoster(s, { decisions: d });
        }
      } else {
        // per-mode FA decision (signed / kept-hold / renounced / traded / override)
        s = setRoster(s, { decisions: { ...curBucket(s).decisions, [player]: decision } });
      }
      return recompute(s);
    }
    case "ADD_PLAYER":      return recompute(setRoster(state, { additions: [...curBucket(state).additions, action.player] }));
    case "REMOVE_ADDITION": {
      let s = setRoster(state, { additions: curBucket(state).additions.filter(p => p.id !== action.id) });
      // Phase 4d: if this addition imposed a signing hard cap, drop it.
      const hc = curSc(s).hardCaps || {};
      if (hc[state.team] && hc[state.team].some(e => e.signId === action.id)) {
        const hcMap = { ...hc };
        const kept = hc[state.team].filter(e => e.signId !== action.id);
        if (kept.length) hcMap[state.team] = kept; else delete hcMap[state.team];
        s = patchScenario(s, { hardCaps: hcMap });
      }
      return recompute(s);
    }
    case "PATCH_ADDITION":  return recompute(setRoster(state, { additions: curBucket(state).additions.map(p =>
                              p.id === action.id ? { ...p, ...action.patch } : p) }));

    /* draft pick management (per-mode) */
    case "SEED_DRAFT_PICKS": return recompute(setRoster(state, { draftPicks: action.picks }));
    case "ADD_DRAFT_PICK":   return recompute(setRoster(state, { draftPicks: [...curBucket(state).draftPicks, action.pick] }));
    case "REMOVE_DRAFT_PICK":return recompute(setRoster(state, { draftPicks: curBucket(state).draftPicks.filter(p => p.id !== action.id) }));
    case "MOVE_DRAFT_PICK": {
      const picks = curBucket(state).draftPicks;
      const idx = picks.findIndex(p => p.id === action.id);
      if (idx < 0) return state;
      const target = idx + action.delta;
      if (target < 0 || target >= picks.length) return state;
      const arr = [...picks];
      [arr[idx], arr[target]] = [arr[target], arr[idx]];
      return recompute(setRoster(state, { draftPicks: arr }));
    }
    case "SET_DRAFT_PICK": return recompute(setRoster(state, { draftPicks: curBucket(state).draftPicks.map(p =>
                             p.id === action.id ? { ...p, ...action.patch } : p) }));

    /* R14.A — REORDER_ROSTER: update the per-team row display order in the
       Trade Machine. Purely visual; doesn't affect cap math or selections.
       action.team = code, action.order = [name, ...]. Pass [] to clear. */
    case "REORDER_ROSTER": {
      const team = action.team;
      if (!team || !Array.isArray(action.order)) return state;
      return { ...state, tmRowOrders: { ...state.tmRowOrders, [team]: action.order } };
    }
    case "SET_TOAST":    return { ...state, toast: action.toast, toastMs: action.ms || null };
    /* Reset one team (both modes) or all teams back to seeded defaults — within
       the ACTIVE scenario only. Other scenarios are unaffected. */
    case "RESET_TEAM": {
      const team = action.team || state.team;
      let s = state;
      // C: first revert any applied trade this team is part of — for ALL parties,
      // not just this team — so a counterparty isn't left holding half a trade.
      // (Reuses the same tag-based reversal as UNDO_TRADE.)
      const applied = curSc(s).appliedTrades;
      const involves = (t) => (t.slots || []).some(sl =>
        sl.code === team || (sl.incoming || []).some(inc => inc.fromCode === team));
      const toRevert = applied.filter(involves);
      if (toRevert.length) {
        let rmap = { ...getRosters(s) };
        for (const t of toRevert) rmap = reverseTradeInRosters(rmap, t);
        s = setRostersMap(s, rmap);
        s = patchScenario(s, { appliedTrades: applied.filter(t => !toRevert.includes(t)) });
      }
      // then clear this team's own options + roster override back to seeded defaults.
      const options = { ...getOpts(s) }; delete options[team];
      const rosters = { ...getRosters(s) }; delete rosters[team];
      s = setOpts(s, options);
      s = setRostersMap(s, rosters);
      // Phase 4d: drop this team's own hard caps + any imposed by the reverted trades.
      const revertedIds = new Set(toRevert.map(t => t.id));
      const hcMap = {};
      for (const [code, list] of Object.entries(curSc(s).hardCaps || {})) {
        if (code === team) continue;
        const kept = (list || []).filter(e => !revertedIds.has(e.tradeId));
        if (kept.length) hcMap[code] = kept;
      }
      s = patchScenario(s, { hardCaps: hcMap });
      // Phase 4f: same for created TPEs (team's own + reverted-trade entries).
      const ctMap = {};
      for (const [code, list] of Object.entries(curSc(s).createdTpes || {})) {
        if (code === team) continue;
        const kept = (list || []).filter(t => !revertedIds.has(t.tradeId));
        if (kept.length) ctMap[code] = kept;
      }
      s = patchScenario(s, { createdTpes: ctMap });
      return recompute(s);
    }
    case "RESET_ALL": {
      let s = setOpts(state, {});
      s = setRostersMap(s, {});
      s = patchScenario(s, { appliedTrades: [], draftTrade: null, hardCaps: {}, createdTpes: {} });   // C: also drop the in-progress (pending) trade + hard caps (4d) + created TPEs (4f)
      return recompute(s);
    }
    case "RESET":        return recompute({ ...initialState, scenarios: { default: emptyScenario() }, activeScenario: "default" });

    /* ============================================================
       R12 — APPLY_TRADE / UNDO_TRADE
       ------------------------------------------------------------
       action.trade = {
         id, ts, desc,
         slots: [
           {
             code,                      // team code
             outPlayers: [{ name, destCode, salaryOverride?, source? }, …],
             outFas:     [{ name, destCode, sntSalary?, sntYears? }, …],
             outPicks:   [{ id, destCode }, …],
             cashOut: number, cashDest: code | null,
             incoming: [{                  // assets coming TO this team
               kind: "player"|"fa"|"pick",
               name?, salary?, years?, raise?, sourcePlayer?, position?,
               id?, label?, year?, round?,
               fromCode,
             }, …],
           }, …
         ],
       }
       Applied changes are folded into BOTH apron AND cap buckets so the
       overlay survives mode switches. Each addition / decision is tagged
       with `_fromTrade: trade.id` so UNDO can reverse precisely.
       ============================================================ */
    case "APPLY_TRADE": {
      const trade = action.trade;
      if (!trade || !Array.isArray(trade.slots)) return state;
      const MODES = ["apron", "cap"];
      let rosters = { ...getRosters(state) };

      for (const sl of trade.slots) {
        const teamRoot = { ...(rosters[sl.code] || {}) };
        for (const mode of MODES) {
          const cur = { ...emptyBucket(), ...(teamRoot[mode] || {}) };
          const decisions = { ...(cur.decisions || {}) };
          const additions = [...(cur.additions || [])];
          let draftPicks = [...(cur.draftPicks || [])];

          // Outgoing players → mark as traded.
          for (const op of (sl.outPlayers || [])) {
            decisions[op.name] = { kind: "traded", destCode: op.destCode || null,
                                   tradeId: trade.id,
                                   salaryOverride: op.salaryOverride,
                                   prev: cur.decisions ? cur.decisions[op.name] : undefined };
          }
          // Outgoing FAs (S&T) → mark as traded too.
          for (const fa of (sl.outFas || [])) {
            decisions[fa.name] = { kind: "traded", destCode: fa.destCode || null,
                                   tradeId: trade.id,
                                   sntSalary: fa.sntSalary, sntYears: fa.sntYears,
                                   prev: cur.decisions ? cur.decisions[fa.name] : undefined };
          }
          // Outgoing picks → remove from draftPicks if present (or mark via _fromTrade).
          if ((sl.outPicks || []).length) {
            const outIds = new Set(sl.outPicks.map(p => p.id));
            const removed = draftPicks.filter(p => outIds.has(p.id));
            draftPicks = draftPicks.filter(p => !outIds.has(p.id));
            // Preserve removed picks on the trade record's slot so UNDO can restore.
            sl._removedPicks = (sl._removedPicks || []).concat(removed);
          }
          // Incoming assets — synthesize roster additions + pick entries.
          for (const inc of (sl.incoming || [])) {
            if (inc.kind === "player" || inc.kind === "fa") {
              const sp = inc.sourcePlayer || {};
              additions.push({
                id: `trade-${trade.id}-${sl.code}-${inc.name}`,
                name: inc.name,
                salary: inc.salary || 0,
                years: inc.years || 1,
                source: `traded from ${inc.fromCode || "—"}`,
                position: inc.position || sp.position || "",
                // R12 bugfix: copy headshot id + bird to the top level so the
                // main-site additions table (and TM overlay) can render the
                // real photo instead of falling back to initials.
                nbaId: sp.nbaId != null ? String(sp.nbaId) : null,
                bird: sp.bird || null,
                _fromTrade: trade.id,
                _sourcePlayer: sp,
              });
            } else if (inc.kind === "pick") {
              draftPicks.push({
                id: inc.id || `trade-${trade.id}-${sl.code}-pick-${inc.year}-${inc.round}`,
                name: inc.label || `${inc.year} ${inc.round === 1 ? "1st" : "2nd"}`,
                year: inc.year, round: inc.round,
                pickNumber: inc.pickNumber || null,
                capHold: inc.capHold || 0,
                disposition: "keep",
                _fromTrade: trade.id,
              });
            }
          }
          teamRoot[mode] = { ...cur, decisions, additions, draftPicks };
        }
        rosters[sl.code] = teamRoot;
      }

      let s = setRostersMap(state, rosters);
      s = patchScenario(s, { appliedTrades: [...curSc(s).appliedTrades, trade] });
      // Phase 4d: persist each side's hard-cap consequence (strictest per team) so
      // it constrains later moves + renders on the bar. One entry per (team, trade)
      // so UNDO_TRADE can drop it precisely.
      const hcMap = { ...(curSc(s).hardCaps || {}) };
      for (const sl of trade.slots) {
        const at = strictestOf(sl.hardCaps);
        if (!at) continue;
        const prior = (hcMap[sl.code] || []).filter(e => e.tradeId !== trade.id);
        hcMap[sl.code] = [...prior, { at, source: `trade ${trade.desc || trade.id}`, tradeId: trade.id }];
      }
      s = patchScenario(s, { hardCaps: hcMap });
      // Phase 4f: persist TPEs created by this trade — BOTH a single-outgoing leg (createdTpe, object)
      // and a pure-dump leg's per-player array (createdTpes). All stamped with tradeId so UNDO drops
      // them together and buildTradeTables re-consumes them as normal capacity.
      const ctMap = { ...(curSc(s).createdTpes || {}) };
      for (const sl of trade.slots) {
        const made = [];
        if (sl.createdTpe && sl.createdTpe.amount > 0) made.push(sl.createdTpe);
        if (Array.isArray(sl.createdTpes)) for (const t of sl.createdTpes) if (t && t.amount > 0) made.push(t);
        if (!made.length) continue;
        const prior = (ctMap[sl.code] || []).filter(t => t.tradeId !== trade.id);
        ctMap[sl.code] = [...prior, ...made.map(t => ({ ...t, tradeId: trade.id }))];
      }
      s = patchScenario(s, { createdTpes: ctMap });
      return recompute(s);
    }

    case "UNDO_TRADE": {
      const applied = curSc(state).appliedTrades;
      if (!applied.length) return state;
      // Undo a SPECIFIC trade by id, else the most recent. With the re-use guard
      // (a player is in at most one trade) applied trades are independent, so any
      // one can be reversed on its own — the old linear-stack restriction (R12.A)
      // is lifted. reverseTradeInRosters also restores each player's pre-trade
      // decision (so cap-mode edits survive the undo).
      const trade = action.tradeId
        ? applied.find(t => t.id === action.tradeId)
        : applied[applied.length - 1];
      if (!trade) return state;

      const rosters = reverseTradeInRosters({ ...getRosters(state) }, trade);
      let s = setRostersMap(state, rosters);
      // Filter by id, not object identity — survives a scenario clone/serialize round-trip
      // (a rehydrated `trade` is a different ref). Matches the hardCaps/createdTpes drops below.
      s = patchScenario(s, { appliedTrades: applied.filter(t => t.id !== trade.id) });
      // Phase 4d: drop any hard-cap entries this trade imposed (any team).
      const hcMap = {};
      for (const [code, list] of Object.entries(curSc(s).hardCaps || {})) {
        const kept = (list || []).filter(e => e.tradeId !== trade.id);
        if (kept.length) hcMap[code] = kept;
      }
      s = patchScenario(s, { hardCaps: hcMap });
      // Phase 4f: drop any TPEs this trade created (any team).
      const ctMap = {};
      for (const [code, list] of Object.entries(curSc(s).createdTpes || {})) {
        const kept = (list || []).filter(t => t.tradeId !== trade.id);
        if (kept.length) ctMap[code] = kept;
      }
      s = patchScenario(s, { createdTpes: ctMap });
      return recompute(s);
    }

    /* Phase 4d — SET_HARD_CAP: persist a hard cap from a SIGNING (NT-MLE/BAE →
       firstApron; TP-MLE → secondApron). Signings don't go through APPLY_TRADE,
       so they tag with signId (= the addition id) and clear via REMOVE_ADDITION. */
    case "SET_HARD_CAP": {
      const { team, at, source, signId } = action;
      if (!team || (at !== "firstApron" && at !== "secondApron")) return state;
      const hc = { ...(curSc(state).hardCaps || {}) };
      const prior = signId ? (hc[team] || []).filter(e => e.signId !== signId) : (hc[team] || []);
      hc[team] = [...prior, { at, source: source || "signing", signId }];
      return recompute(patchScenario(state, { hardCaps: hc }));
    }

    /* one-world #14, Phase 4 — TM_SET_DRAFT: the Trade Machine's in-progress
       deal. TradeMachineView computes the next slots (plain objects) and
       dispatches; the board is now part of the shared, persisted scenario
       (capmvp-state) instead of a separate localStorage store. No recompute
       needed — draftTrade doesn't feed the options/rosters mirrors. */
    case "TM_SET_DRAFT": {
      const slots = Array.isArray(action.slots) ? action.slots : [];
      return patchScenario(state, { draftTrade: { slots } });
    }

    /* Scenario management — gated UI per R12.B; the actions are wired now
       so the toggle can ship without further reducer changes. */
    case "CREATE_SCENARIO": {
      const id = action.id || `scenario-${Date.now()}`;
      const name = action.name || `Scenario ${Object.keys(state.scenarios).length + 1}`;
      const src = action.fromActive ? curSc(state) : emptyScenario();
      const next = { ...src, name, appliedTrades: [...(src.appliedTrades || [])],
                     options: { ...(src.options || {}) }, rosters: { ...(src.rosters || {}) },
                     hardCaps: { ...(src.hardCaps || {}) },
                     createdTpes: { ...(src.createdTpes || {}) },
                     draftTrade: null };   // a new scenario starts with no in-progress trade
      return recompute({ ...state,
        scenarios: { ...state.scenarios, [id]: next },
        activeScenario: id });
    }
    case "LOAD_SCENARIO": {
      if (!state.scenarios[action.id]) return state;
      return recompute({ ...state, activeScenario: action.id });
    }
    case "DELETE_SCENARIO": {
      if (action.id === "default") return state; // protect the base
      if (!state.scenarios[action.id]) return state;
      const scenarios = { ...state.scenarios }; delete scenarios[action.id];
      const activeScenario = state.activeScenario === action.id ? "default" : state.activeScenario;
      return recompute({ ...state, scenarios, activeScenario });
    }
    case "RENAME_SCENARIO": {
      const sc = state.scenarios[action.id];
      if (!sc) return state;
      return { ...state, scenarios: { ...state.scenarios, [action.id]: { ...sc, name: action.name } } };
    }
    default: return state;
  }
}

const PERSIST_KEY = "capmvp-state";
function loadPersistedState() {
  try {
    const raw = localStorage.getItem(PERSIST_KEY);
    if (!raw) return null;
    const p = JSON.parse(raw);
    return (p && typeof p === "object") ? p : null;
  } catch (e) { return null; }
}
/* R12 migration shim: v1 stored top-level options/rosters; v2 wraps them
   inside scenarios.default. Always returns a v2-shaped object. */
function migrateState(p) {
  if (!p) return null;
  if (p.scenarios && p.activeScenario) return p;             // already v2
  const def = {
    options: p.options || {},
    rosters: p.rosters || {},
    appliedTrades: p.appliedTrades || [],
    hardCaps: p.hardCaps || {},
    createdTpes: p.createdTpes || {},
  };
  return {
    team: p.team, mode: p.mode,
    modeByTeam: p.modeByTeam, modeConfirmed: p.modeConfirmed, capForAllTeams: p.capForAllTeams,
    multiYear: p.multiYear, adjustRaises: true,   // [tweak f] always on (gear toggle removed; force on restore)
    scenarios: { default: def },
    activeScenario: "default",
  };
}
// The only teams that realistically reach useful cap space. Only these (plus
// any team when the gear "All teams" master is on) are offered "Use cap space",
// and only these get the Over/Cap-space decision row atop Decide-these-first.
// BKN + CHI default to cap; the rest default to over-the-cap.
const CAP_SPACE_TEAMS = ["BKN", "CHI", "LAL", "LAC", "DET"];
const CAP_DEFAULT_TEAMS = ["BKN", "CHI"];

function useAppState() {
  const [state, dispatch] = useReducer(appReducer, null, () => {
    const p = migrateState(loadPersistedState());
    const base = { ...initialState, ...(p || {}), multiYear: true, toast: null };  // multi-year is always on now (Chunk 2)
    // Per-team mode memory. Curated cap-space teams (CAP_DEFAULT_TEAMS — BKN/CHI)
    // seed straight into cap MODE so their cap sheet opens in the right view; the
    // Over/Cap-space decision row still pre-selects cap for them as well.
    // Confirming cap there switches the mode. Persisted picks win (spread order).
    const capDefaults = {};
    for (const code of (CAP_DEFAULT_TEAMS || [])) capDefaults[code] = "cap";   // curated cap-space teams default to cap mode (a user's saved pick still wins via the spread order)
    base.modeByTeam = { [base.team]: base.mode, ...capDefaults, ...(base.modeByTeam || {}) };
    base.mode = base.modeByTeam[base.team] || base.mode;
    base.modeConfirmed = base.modeConfirmed || {};
    // one-world #14, Phase 4: one-time migration of a legacy in-progress trade
    // (the old separate localStorage["capmvp-tm-trade"], Map-encoded) into the
    // active scenario's draftTrade, then retire the key.
    try {
      const aid = base.activeScenario || "default";
      base.scenarios = base.scenarios || { default: emptyScenario() };
      const asc = base.scenarios[aid];
      if (asc && asc.draftTrade == null) {
        const raw = localStorage.getItem("capmvp-tm-trade");
        if (raw) {
          const legacy = JSON.parse(raw, (k, v) => (v && typeof v === "object" && Array.isArray(v.__m)) ? new Map(v.__m) : v);
          if (Array.isArray(legacy) && legacy.length)
            base.scenarios[aid] = { ...asc, draftTrade: { slots: legacy.map(slotMapToObj) } };
          localStorage.removeItem("capmvp-tm-trade");
        }
      }
    } catch (e) {}
    return recompute(base);
  });
  // Persist the source-of-truth subset (omit derived mirrors + toast).
  useEffect(() => {
    try {
      const persist = {
        team: state.team, mode: state.mode,
        modeByTeam: state.modeByTeam, modeConfirmed: state.modeConfirmed, capForAllTeams: state.capForAllTeams,
        multiYear: state.multiYear, adjustRaises: state.adjustRaises,
        scenarios: state.scenarios,
        activeScenario: state.activeScenario,
        tmRowOrders: state.tmRowOrders,
      };
      localStorage.setItem(PERSIST_KEY, JSON.stringify(persist));
    } catch (e) {}
  }, [state.team, state.mode, state.modeByTeam, state.modeConfirmed, state.capForAllTeams, state.multiYear, state.adjustRaises, state.scenarios, state.activeScenario, state.tmRowOrders]);
  return [state, dispatch];
}

/* ============================================================
   Cap-hold calculation (matches PROJECT_NOTES.md rules)
   ============================================================ */
function computeCapHold(p) {
  if (p.capHold != null) return p.capHold;
  if (!p.priorSeasonSalary) return 0;
  const prev = p.priorSeasonSalary;
  // Rookie-scale-expiring exception. Unknown/null ⇒ treated as NO (the
  // field is filled per-player later); only an explicit truthy value
  // triggers the exception holds.
  const expiring = !!p.rookieScaleExpiring;
  if (p.birdRights === "Bird") {
    // Post-rookie first-rounder: 300% (prev < avg) / 250% (≥ avg); else
    // the standard 190% / 150%. Always capped at the player's max.
    const pct = expiring
      ? (prev < CAP_2026.leagueAvg ? 3.00 : 2.50)
      : (prev < CAP_2026.leagueAvg ? 1.90 : 1.50);
    return Math.round(Math.min(prev * pct, maxEligible(p)));
  }
  if (p.birdRights === "EarlyBird") {
    // 2nd-year rookie-scale exception: hold jumps to the max payable.
    if (expiring) return maxEligible(p);
    return Math.round(prev * 1.30);
  }
  if (p.birdRights === "NonBird")   return Math.round(prev * 1.20);
  return Math.round(prev * 1.20);
}

function holdFormula(p) {
  if (!p.priorSeasonSalary) return "";
  const prev = p.priorSeasonSalary;
  const expiring = !!p.rookieScaleExpiring;
  if (p.birdRights === "Bird") {
    if (expiring) {
      const pct = prev < CAP_2026.leagueAvg ? "300%" : "250%";
      return `${pct} × ${fmt$(prev)} (post-rookie, capped at max)`;
    }
    if (prev < CAP_2026.leagueAvg) return `190% × ${fmt$(prev)}`;
    const at35 = maxEligible(p);
    const naive = prev * 1.5;
    if (naive > at35) return `min(150% × ${fmt$(prev)}, max @ ${fmt$(at35)})`;
    return `150% × ${fmt$(prev)}`;
  }
  if (p.birdRights === "EarlyBird") {
    if (expiring) return `max @ ${fmt$(maxEligible(p))} (2nd-yr rookie)`;
    return `130% × ${fmt$(prev)}`;
  }
  return `120% × ${fmt$(prev)}`;
}

/* salary contribution rules
   - under_contract: 2026-27 salary
   - player_option opt-in: option salary; opt-out: cap hold if in cap mode, else 0
   - team_option exercise: option salary; decline: 0
   - non-guar keep: salary; waive: 0
   - UFA re-sign: signed salary; keep hold: hold (cap mode only); renounce: 0
   - RFA: cap hold if applicable (we'll add the major one or skip if no value)
   - additions: counted in
   - draftPick: rookie-scale salary always (see draftPickSalary); the
     draft_pick roster entries themselves return 0 here (tracked via
     state.draftPicks)
*/
/* Re-signing a Bird / Early-Bird FA ABOVE their cap hold uses the Bird
   exception (over the cap) — it doesn't consume cap room, so the cap MATH
   keeps the (lower) hold. The full salary is the eventual contract (shown
   to the user separately). Below-hold signings count the real salary. */
function cappedSignedSalary(p, salary, mode, apronView) {
  // Final Roster (apron stage) shows the FULL contract — no hold cap.
  if (apronView) return salary;
  if (mode === "cap" && !p._lite
      && (p.birdRights === "Bird" || p.birdRights === "EarlyBird")) {
    const hold = computeCapHold(p);
    if (salary > hold) return hold;
  }
  return salary;
}

// Dead-money charge for a WAIVED player, per the decision: a negotiated BUYOUT
// amount, a STRETCHED per-year share (precomputed via the engine in the waive panel),
// or — default — the STRAIGHT guaranteed charge the caller passes for that status.
function waivedCharge(p, d, straight) {
  if (d && d.deadMoneyKind === "buyout"   && d.buyoutAmount  != null) return d.buyoutAmount;
  if (d && d.deadMoneyKind === "stretched" && d.stretchPerYear != null) return d.stretchPerYear;
  return straight;
}

const _pesWarned = new Set();   // dedup the unhandled-status console.warn (default branch)
function playerEffectiveSalary(p, decisions, mode, apronView) {
  const d = decisions[p.name];
  const s2627 = p.seasons?.find(s => s.season === "2026-27")?.salary || 0;

  // Pre-existing dead money (waived-and-stretched contracts like MIL Lillard /
  // PHX Beal, flagged `deadMoney` in the data): a FIXED 2026-27 cap charge — not a
  // tradeable asset, not an option/decision. Always counts at its season salary in
  // both base and apron Team Salary (display = a tombstone, via bucketPlayers).
  if (p.deadMoney) return s2627;

  // Two-way contracts are EXCLUDED from Team Salary (CBA Art VIII) — in BOTH base and
  // apron. buildTeams (helpers.jsx) + __capBaseline (engine-bridge) already exclude them;
  // this aligns the ACTIVE team's total, which was adding ~78 FA two-way cap holds in cap
  // mode (and a two-way's salary in apron), so the salary bar disagreed with capBase.
  if (p.contractType === "two_way") return 0;

  // Traded away in the Trade Machine — off the books entirely, whatever
  // their contract status was.
  if (d?.kind === "traded") return 0;
  // A manual salary override (set via the pencil in the roster) wins for
  // any committed player — but not if they've been waived/renounced.
  if (d?.salaryOverride != null && d.kind !== "waive"
      && d.kind !== "renounced" && d.kind !== "decline" && d.kind !== "opt-out") {
    return d.salaryOverride;
  }

  switch (p.offseasonStatus) {
    case "under_contract":
      // Waiving a fully-guaranteed contract leaves the WHOLE salary as dead money
      // (buyout/stretch can reduce/spread it — waivedCharge).
      if (d?.kind === "waive") return waivedCharge(p, d, s2627);
      return s2627;
    case "partially_guaranteed":   // FIX: route here — full salary while KEPT; guaranteed floor as dead money if WAIVED (same model as non_guaranteed; see comment below)
    case "non_guaranteed":
      // KEPT (or undecided) → FULL salary; a partially-guaranteed player is a
      // live contract while on the roster (handled by the s2627 return below).
      // WAIVING is what creates dead money: a partial leaves its GUARANTEED
      // amount on the books (you save the non-guaranteed remainder); a fully
      // non-guaranteed min deal leaves $0. Dead money is downstream of the
      // waive — never a property of the kept contract.
      if (d?.kind === "waive") return waivedCharge(p, d, guaranteedAmountFor(p));
      if (d?.kind === "signed") return d.salary || 0;
      // honor a manual override if present (used when source data is wrong)
      if (d?.salaryOverride != null) return d.salaryOverride;
      return s2627;
    case "player_option": {
      // Opt-out → a free agent with a cap hold (cap mode only; apron
      // ignores holds). Re-sign / renounce clear it.
      if (d?.kind === "signed") return cappedSignedSalary(p, d.salary || 0, mode, apronView);  // re-signed FA
      if (d?.kind === "renounced") return 0;
      if (d?.kind === "opt-out" || d?.kind === "kept-hold")
        return (mode === "cap" && !p._lite) ? computeCapHold(p) : 0;
      if (d?.kind === "waive") return waivedCharge(p, d, p.optionSalary || s2627);
      if (d?.salaryOverride != null) return d.salaryOverride;
      // opt-in / undecided: option-year salary on the books. (The 4 null-optionSalary
      // edge cases that briefly needed a currentSalary fallback were resolved in the
      // data on 2026-06-03 — dropped or reclassified — so the fallback is gone.)
      return p.optionSalary || s2627 || 0;
    }
    case "team_option": {
      if (d?.kind === "signed") return cappedSignedSalary(p, d.salary || 0, mode, apronView);  // re-signed after decline
      if (d?.kind === "renounced") return 0;
      // A declined team option becomes a free agent WITH a cap hold (like
      // a player opt-out) — CBA-correct. Renounce to clear it.
      if (d?.kind === "decline" || d?.kind === "kept-hold")
        return (mode === "cap" && !p._lite) ? computeCapHold(p) : 0;
      if (d?.kind === "waive") return waivedCharge(p, d, p.optionSalary || s2627);
      if (d?.salaryOverride != null) return d.salaryOverride;
      return p.optionSalary || s2627 || 0; // exercise / default
    }
    case "UFA": {
      if (d?.kind === "signed")    return cappedSignedSalary(p, d.salary || 0, mode, apronView);
      if (d?.kind === "renounced") return 0;
      // keep-hold or undecided
      return (mode === "cap" && !p._lite) ? computeCapHold(p) : 0;
    }
    case "RFA": {
      if (d?.kind === "signed")    return cappedSignedSalary(p, d.salary || 0, mode, apronView);
      if (d?.kind === "renounced") return 0;
      // CAP base: the full RFA hold (data capHold = greater-of(FA-amount, QO) — authoritative).
      // APRON base (§2(e)(1)(v)): NOT the FA Amount — it's the greater of (QO/max-QO, First-Refusal
      // Exercise Notice). Engine restrictedFreeAgentHold(measure:"apron") = max(QO, FRE). The 8
      // rookie-scale RFAs ship no qualifyingOffer → fall back to capHold (a conservative proxy until
      // the rookie-scale QO table ships). Was hard-coded 0, which understated the apron base for RFA
      // teams; fixing it moved __capBaseline (re-baselined, owner-approved 2026-06-04).
      if (p._lite) return 0;
      if (mode === "cap") return p.capHold || 0;
      const E = (typeof window !== "undefined" && window.Engine) || {};
      return (typeof E.restrictedFreeAgentHold === "function")
        ? (E.restrictedFreeAgentHold({ qualifyingOffer: (p.qualifyingOffer != null ? p.qualifyingOffer : p.capHold), measure: "apron" }) || 0)
        : 0;
    }
    case "draft_pick":
      return 0;
    default:
      // Surface (once) any future data status we don't price — instead of silently $0.
      if (p.offseasonStatus && !_pesWarned.has(p.offseasonStatus)) {
        _pesWarned.add(p.offseasonStatus);
        if (typeof console !== "undefined") console.warn("[playerEffectiveSalary] unhandled offseasonStatus → priced $0:", p.offseasonStatus, "(" + (p.name || "?") + ")");
      }
      return 0;
  }
}

/* On-books salary for a managed draft pick. "keep" / "sign-and-trade"
   carry the rookie-scale amount (stored capHold if known, else the
   pick-number scale, else vet-min charge); "trade-pick" is $0 (the pick
   is dealt before the draft). Always counts now — a drafted rookie's
   salary is real money in apron mode too, not just a cap-mode hold. */
function draftPickSalary(dp) {
  if (!dp || dp.disposition === "trade-pick") return 0;
  return dp.capHold || capHoldForPick(dp.pickNumber) || 0;
}

/* ============================================================
   Derived: committed salary, status, tool flags
   ============================================================ */
/* computeDerived — the PURE body of useDerived (one-world #14, Phase 2). Takes a
   team's resolved (decisions, mode, additions, draftPicks) explicitly instead of
   reading `state`, so it can be run for the active team (via useDerived) AND for
   every other team (via App's derivedByTeam selector) from the ONE store. The
   logic is byte-identical to the old hook body. */
function computeDerived(players, decisions, mode, additions, draftPicks, apronView) {
    let committed = 0, unlikely = 0;

    // players from roster json (excluding the draft_pick entries — those are
    // tracked via the draftPicks list now)
    for (const p of players) {
      if (p.offseasonStatus === "draft_pick") continue;
      const sal = playerEffectiveSalary(p, decisions, mode, apronView);
      committed += sal;
      if (sal > 0) unlikely += Number(p.unlikelyBonus) || 0;   // R4: unlikely bonuses (apron-only) — drives the chart toggle
    }
    // additions (FA signings, trade-ins)
    for (const a of additions) committed += a.salary || 0;
    // draft picks — ALWAYS on the books now (rookie salary is real in
    // apron too); "trade-pick" disposition zeroes out via draftPickSalary
    for (const dp of draftPicks) committed += draftPickSalary(dp);

    // Actual contracted roster count (cap holds / FAs not yet signed do
    // NOT occupy a roster spot).
    let rosterCount = 0, twoWayCount = 0;
    for (const p of players) {
      if (p.deadMoney) continue;   // dead money doesn't occupy a roster spot
      const k = decisions[p.name]?.kind;
      // Two-way players occupy a SEPARATE list (max 3), not the 15 standard slots, and
      // don't count toward the 12-man incomplete-roster minimum (CBA Art VIII). Track them
      // apart so rosterCount stays the STANDARD count (Phase 4 enforces both limits).
      if (p.contractType === "two_way") {
        if (k === "waive" || k === "renounced" || k === "traded") continue;
        if ((p.offseasonStatus === "UFA" || p.offseasonStatus === "RFA") && k !== "signed") continue;
        twoWayCount++;
        continue;
      }
      if (p.offseasonStatus === "draft_pick" || p.offseasonStatus === "UFA"
          || p.offseasonStatus === "RFA") {
        if (k === "signed") rosterCount++;
        continue;
      }
      if (k === "signed") { rosterCount++; continue; }
      if (k === "waive" || k === "renounced" || k === "traded"
          || k === "opt-out" || k === "decline" || k === "kept-hold") continue;
      rosterCount++; // under_contract / opt-in / exercise / kept non-guar
    }
    rosterCount += additions.length;
    rosterCount += draftPicks.filter(dp => dp.disposition !== "trade-pick").length;

    // Incomplete-roster charge: cap mode only, only below the 12-man
    // minimum, valued at the 0-years rookie minimum.
    const incompleteSlots = (mode === "cap")
      ? Math.max(0, CAP_2026.rosterMin - rosterCount) : 0;
    committed += incompleteSlots * CAP_2026.incompleteCharge;

    const { cap, taxLine, apron1, apron2 } = CAP_2026;
    let zone = "under-cap";
    if (committed > apron2) zone = "over-apron2";
    else if (committed > apron1) zone = "apron-zone-2";
    else if (committed > taxLine) zone = "apron-zone-1";
    else if (committed > cap) zone = "over-cap";

    // Tool availability is keyed to the apron lines, where CAP HOLDS DON'T COUNT
    // (only real salaries do). In cap mode `committed` includes holds (for the
    // cap-room line), so recompute an apron-only total by re-running the salary
    // calc as if over-cap (mode "apron" zeros every hold branch). Apron mode is
    // already holds-free, so this equals `committed` there. Drives tools only —
    // the salary bar / deltas keep using `committed`/`zone`.
    let apronTotal = committed;
    if (mode === "cap") {
      apronTotal = 0;
      for (const p of players) {
        if (p.offseasonStatus === "draft_pick") continue;
        apronTotal += playerEffectiveSalary(p, decisions, "apron", apronView);
      }
      for (const a of additions) apronTotal += a.salary || 0;
      for (const dp of draftPicks) apronTotal += draftPickSalary(dp);
    }
    let toolZone = "under-cap";
    if (apronTotal > apron2) toolZone = "over-apron2";
    else if (apronTotal > apron1) toolZone = "apron-zone-2";
    else if (apronTotal > taxLine) toolZone = "apron-zone-1";
    else if (apronTotal > cap) toolZone = "over-cap";

    // tool availability (keyed to the holds-free apron total)
    const toolsAvail = TOOLS.map(t => {
      let avail = true;
      if (toolZone === "over-apron2" && t.cap !== null) avail = false;
      else if (toolZone === "apron-zone-2" && t.cap === "apron1") avail = false;
      return { ...t, avail };
    });
    const availCount = toolsAvail.filter(t => t.avail).length;

    // roster spots (guaranteed contracts in committed state). A WAIVED player
    // never occupies a roster spot — even a partial whose dead money now makes
    // eff > 0 — so skip `waive` (and the other off-roster decisions) explicitly
    // rather than keying off eff > 0.
    let spots = 0;
    for (const p of players) {
      if (p.deadMoney) continue;   // dead money doesn't occupy a roster spot
      if (p.offseasonStatus === "draft_pick" || p.offseasonStatus === "RFA") continue;
      const k = decisions[p.name]?.kind;
      if (k === "waive" || k === "renounced" || k === "traded"
          || k === "opt-out" || k === "decline") continue;
      const eff = playerEffectiveSalary(p, decisions, mode);
      if (eff > 0) spots++;
    }
    spots += additions.length;
    // kept (and sign-and-trade) picks fill a roster spot; trade-pick doesn't
    spots += draftPicks.filter(dp => dp.disposition !== "trade-pick").length;

    const isUnderCap = committed <= CAP_2026.cap;

    return { committed, apronTotal, unlikely, zone, toolZone, toolsAvail, availCount, spots, isUnderCap, rosterCount, twoWayCount };
}

/* useDerived = computeDerived for the ACTIVE team, memoized on its inputs.
   Unchanged behavior — the body just moved into the pure computeDerived above. */
function useDerived(players, state, apronView) {
  return useMemo(
    () => computeDerived(players, state.decisions, state.mode, state.additions, state.draftPicks, apronView),
    [players, state.decisions, state.mode, state.additions, state.draftPicks, apronView]
  );
}

Object.assign(window, {
  initialState, appReducer, useAppState, useDerived, computeDerived, recompute, OPTION_KINDS,
  strictestHardCap, hardCapBarString,
  computeCapHold, holdFormula, playerEffectiveSalary, draftPickSalary,
  cappedSignedSalary, CAP_SPACE_TEAMS, CAP_DEFAULT_TEAMS,
  /* R12 scenario helpers (read-only — write via dispatch). */
  curSc, getOpts, getRosters, emptyScenario, selectTeamView,
  slotObjToMap, slotMapToObj, emptyDraftSlot,
});
