diff --git a/app.py b/app.py index e3197b0..8bcefe5 100644 --- a/app.py +++ b/app.py @@ -8,9 +8,66 @@ DB_PATH = "tarkov.db" def get_db(): conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") return conn +def _migrate_key_ids_and_maps(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = OFF") + + # Backfill missing key IDs with their api_id so ratings can join correctly. + conn.execute(""" + UPDATE keys + SET id = api_id + WHERE id IS NULL AND api_id IS NOT NULL + """) + + # If key_maps was created with INTEGER key_id, migrate to TEXT to match keys.id. + cols = conn.execute("PRAGMA table_info(key_maps)").fetchall() + key_id_type = None + if cols: + for col in cols: + if col["name"] == "key_id": + key_id_type = (col["type"] or "").upper() + break + if key_id_type and key_id_type != "TEXT": + conn.execute("ALTER TABLE key_maps RENAME TO key_maps_old") + conn.execute(""" + CREATE TABLE key_maps ( + key_id TEXT NOT NULL, + map_id INTEGER NOT NULL, + PRIMARY KEY (key_id, map_id), + FOREIGN KEY (key_id) REFERENCES keys(id), + FOREIGN KEY (map_id) REFERENCES maps(id) + ) + """) + conn.execute(""" + INSERT OR IGNORE INTO key_maps (key_id, map_id) + SELECT CAST(key_id AS TEXT), map_id + FROM key_maps_old + WHERE key_id IS NOT NULL + AND EXISTS (SELECT 1 FROM keys WHERE id = CAST(key_id AS TEXT)) + """) + conn.execute("DROP TABLE key_maps_old") + + # Remove orphaned ratings created with "None" or missing keys. + conn.execute(""" + DELETE FROM key_ratings + WHERE key_id IS NULL + OR key_id = 'None' + OR key_id NOT IN (SELECT id FROM keys) + """) + + conn.commit() + conn.execute("PRAGMA foreign_keys = ON") + conn.close() + + +_migrate_key_ids_and_maps() + + @app.route("/") def index(): conn = get_db() @@ -198,6 +255,7 @@ def rate_all(): def quests(): conn = get_db() only_collector = request.args.get("collector") == "1" + view = request.args.get("view", "flow") # "flow" or "list" # All quests + done state all_quests = conn.execute(""" @@ -243,7 +301,7 @@ def quests(): # Filter to collector-only if requested if only_collector: - visible = collector_prereqs | {collector_row["id"]} + visible = set(collector_prereqs) else: visible = set(quest_by_id.keys()) @@ -270,12 +328,14 @@ def quests(): visible=visible, collector_prereqs=collector_prereqs, only_collector=only_collector, + view=view, ) @app.route("/collector") def collector(): conn = get_db() + view = request.args.get("view", "flow") collector = conn.execute( "SELECT id FROM quests WHERE name = 'Collector'" ).fetchone() @@ -284,33 +344,76 @@ def collector(): conn.close() return "Run import_quests.py first to populate quest data.", 503 - # Recursive CTE: all transitive prerequisites, then keep only leaves - # (quests that are not themselves a dependency of another prereq) - prereqs = conn.execute(""" + # All quests + done state + all_quests = conn.execute(""" + SELECT q.id, q.name, q.trader, q.wiki_link, + COALESCE(qp.done, 0) AS done + FROM quests q + LEFT JOIN quest_progress qp ON q.id = qp.quest_id + ORDER BY q.trader, q.name + """).fetchall() + + # All dependency edges + all_deps = conn.execute("SELECT quest_id, depends_on FROM quest_deps").fetchall() + + # Collector prereq set (transitive) + rows = conn.execute(""" WITH RECURSIVE deps(quest_id) AS ( SELECT depends_on FROM quest_deps WHERE quest_id = ? UNION SELECT qd.depends_on FROM quest_deps qd JOIN deps d ON qd.quest_id = d.quest_id ) - SELECT q.id, q.name, q.trader, q.wiki_link, - COALESCE(qp.done, 0) AS done - FROM quests q - JOIN deps d ON q.id = d.quest_id - LEFT JOIN quest_progress qp ON q.id = qp.quest_id - WHERE q.id NOT IN ( - SELECT qd2.depends_on - FROM quest_deps qd2 - WHERE qd2.quest_id IN (SELECT quest_id FROM deps) - AND qd2.depends_on IN (SELECT quest_id FROM deps) - ) - ORDER BY q.trader, q.name + SELECT quest_id FROM deps """, (collector["id"],)).fetchall() + collector_prereqs = {r[0] for r in rows} conn.close() - total = len(prereqs) - done = sum(1 for q in prereqs if q["done"]) - return render_template("collector.html", quests=prereqs, total=total, done=done) + + # Build lookup structures + quest_by_id = {q["id"]: q for q in all_quests} + # children[parent_id] = [child_id, ...] (child depends_on parent) + children = {} + parents = {} + for dep in all_deps: + child, parent = dep["quest_id"], dep["depends_on"] + children.setdefault(parent, []).append(child) + parents.setdefault(child, []).append(parent) + # Sort each child list by quest name + for parent_id in children: + children[parent_id].sort(key=lambda i: quest_by_id[i]["name"] if i in quest_by_id else "") + + visible = set(collector_prereqs) + + # Root quests: in visible set and have no parents also in visible set + roots = [ + qid for qid in visible + if not any(p in visible for p in parents.get(qid, [])) + ] + + # Group roots by trader, sorted + trader_roots = {} + for qid in sorted(roots, key=lambda i: (quest_by_id[i]["trader"], quest_by_id[i]["name"])): + t = quest_by_id[qid]["trader"] + trader_roots.setdefault(t, []).append(qid) + + traders = sorted(trader_roots.keys()) + + total = len(collector_prereqs) + done = sum(1 for qid in collector_prereqs if qid in quest_by_id and quest_by_id[qid]["done"]) + return render_template( + "collector.html", + quest_by_id=quest_by_id, + children=children, + trader_roots=trader_roots, + traders=traders, + visible=visible, + collector_prereqs=collector_prereqs, + collector_id=collector["id"], + total=total, + done=done, + view=view, + ) @app.route("/collector/toggle", methods=["POST"]) diff --git a/import_keys.py b/import_keys.py index 3d48aaf..8b22474 100644 --- a/import_keys.py +++ b/import_keys.py @@ -62,9 +62,7 @@ def upsert_keys(conn, keys): continue cursor.execute( - """ - SELECT id FROM keys WHERE api_id = ? - """, + "SELECT id FROM keys WHERE api_id = ?", (api_id,) ) row = cursor.fetchone() @@ -73,19 +71,20 @@ def upsert_keys(conn, keys): cursor.execute( """ UPDATE keys - SET name = ?, short_name = ?, weight_kg = ?, uses = ?, wiki_url = ?, grid_image_url = ? + SET id = COALESCE(id, ?), + name = ?, short_name = ?, weight_kg = ?, uses = ?, wiki_url = ?, grid_image_url = ? WHERE api_id = ? """, - (name, short_name, weight, uses, wiki_url, grid_image_url, api_id) + (api_id, name, short_name, weight, uses, wiki_url, grid_image_url, api_id) ) updated += 1 else: cursor.execute( """ - INSERT INTO keys (api_id, name, short_name, weight_kg, uses, wiki_url, grid_image_url) - VALUES (?, ?, ?, ?, ?, ?, ?) + INSERT INTO keys (id, api_id, name, short_name, weight_kg, uses, wiki_url, grid_image_url) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, - (api_id, name, short_name, weight, uses, wiki_url, grid_image_url) + (api_id, api_id, name, short_name, weight, uses, wiki_url, grid_image_url) ) inserted += 1 diff --git a/tarkov.db b/tarkov.db index 3e8aef5..7d360ce 100644 Binary files a/tarkov.db and b/tarkov.db differ diff --git a/templates/collector.html b/templates/collector.html index d7146f0..227ae9c 100644 --- a/templates/collector.html +++ b/templates/collector.html @@ -1,3 +1,62 @@ +{# ══════════════════════════════════════════════ + MACROS — must come before first use in Jinja2 +══════════════════════════════════════════════ #} + +{# List view: indented tree with ├── / └── connector lines. + open_stack: list of booleans — True = ancestor has more siblings (draw vert), False = last (draw blank). + is_last: whether this node is the last sibling among its parent's children. #} +{% macro render_list_item(qid, quest_by_id, children, visible, collector_prereqs, open_stack, is_last, collector_id) %} +{% set q = quest_by_id[qid] %} +{% set visible_kids = [] %} +{% for cid in children.get(qid, []) %} + {% if cid in visible %}{% set _ = visible_kids.append(cid) %}{% endif %} +{% endfor %} +
+
+ {% for open in open_stack %} +
+ {% endfor %} + {% if open_stack %} +
+ {% endif %} +
+
+ {% if qid in collector_prereqs %}{% endif %} + {{ q.name }} + {% if q.wiki_link %}wiki{% endif %} + {% if qid != collector_id %}{% endif %} +
+
+{% set child_stack = open_stack + [not is_last] %} +{% for cid in visible_kids %} + {% set child = quest_by_id[cid] %} + {% set child_last = loop.last %} + {% if child.trader != q.trader %} +
+
+ {% for open in child_stack %} +
+ {% endfor %} +
+
+
+ {% if cid in collector_prereqs %}{% endif %} + {{ child.name }} + {% if child.wiki_link %}wiki{% endif %} + {{ child.trader }} + {% if cid != collector_id %}{% endif %} +
+
+ {% else %} + {{ render_list_item(cid, quest_by_id, children, visible, collector_prereqs, child_stack, child_last, collector_id) }} + {% endif %} +{% endfor %} +{% endmacro %} + @@ -8,12 +67,15 @@ --bg: #121212; --panel: #1a1a1a; --text: #eee; - --muted: #bbb; - --border: #333; + --muted: #888; + --border: #2a2a2a; --accent: #9ccfff; - --done-bg: #1a2a1a; --done-text: #6ec96e; + --done-bg: #1a2a1a; + --kappa: #f0c040; + --line: #333; } + * { box-sizing: border-box; } body { font-family: sans-serif; background: var(--bg); @@ -21,11 +83,29 @@ margin: 0; padding: 16px; } - .page { - max-width: 780px; - margin: 0 auto; + .page { max-width: 960px; margin: 0 auto; } + nav { margin-bottom: 16px; font-size: 0.9rem; } + nav a { color: var(--accent); } + h1 { margin: 0 0 4px; } + .toolbar { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + margin-bottom: 12px; } - h1 { margin-bottom: 4px; } + .filter-btn { + background: var(--panel); + border: 1px solid #444; + color: var(--text); + border-radius: 6px; + padding: 5px 14px; + cursor: pointer; + font-size: 0.85rem; + text-decoration: none; + } + .filter-btn.active { border-color: var(--kappa); color: var(--kappa); } + .sep { color: var(--border); } .subtitle { color: var(--muted); margin: 0 0 16px; @@ -45,58 +125,166 @@ border-radius: 999px; transition: width 0.3s; } - .trader-group { margin-bottom: 8px; } - .trader-header { + .legend { + display: flex; + gap: 16px; font-size: 0.8rem; - font-weight: bold; - text-transform: uppercase; - letter-spacing: 0.08em; color: var(--muted); - padding: 12px 0 4px; - border-bottom: 1px solid var(--border); - margin-bottom: 4px; + flex-wrap: wrap; + margin-bottom: 16px; } - .quest-row { + .legend span { display: flex; align-items: center; gap: 5px; } + + /* Trader section */ + .trader-section { margin-bottom: 8px; } + .trader-header { display: flex; align-items: center; - gap: 10px; - padding: 8px 4px; - border-bottom: 1px solid #222; - border-radius: 4px; + gap: 8px; + padding: 10px 8px; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 6px; + cursor: pointer; + user-select: none; } - .quest-row.done { - background: var(--done-bg); - } - .quest-row.done .quest-name { - text-decoration: line-through; - color: var(--done-text); - } - .quest-name { - flex: 1; + .trader-header:hover { border-color: #444; } + .trader-name { + font-weight: bold; font-size: 0.95rem; + flex: 1; } - .quest-name a { - color: var(--accent); - font-size: 0.8rem; - margin-left: 6px; + .trader-counts { font-size: 0.8rem; color: var(--muted); } + .chevron { color: var(--muted); font-size: 0.8rem; transition: transform 0.15s; } + .trader-section.collapsed .chevron { transform: rotate(-90deg); } + .trader-body { padding: 6px 0 6px 8px; } + .trader-section.collapsed .trader-body { display: none; } + + /* Flow tree */ + .tree-root { + margin: 6px 0; + display: flex; + flex-direction: column; + align-items: center; + position: relative; + } + .tree-children { + display: flex; + gap: 14px; + align-items: flex-start; + justify-content: center; + position: relative; + padding-top: 18px; + flex-wrap: wrap; + } + .tree-children:before { + content: ""; + position: absolute; + top: 8px; + left: 8px; + right: 8px; + height: 1px; + background: var(--border); + opacity: 0.9; + } + .tree-children > .tree-root { + padding-top: 8px; + } + .tree-children > .tree-root:before { + content: ""; + position: absolute; + top: 0; + left: 50%; + width: 1px; + height: 8px; + background: var(--border); + } + .quest-node { + display: flex; + align-items: center; + gap: 8px; + padding: 5px 6px; + border-radius: 4px; + margin: 2px 0; + position: relative; + background: #141820; + border: 1px solid #1b2230; + } + .quest-node.has-children:after { + content: ""; + position: absolute; + left: 50%; + bottom: -8px; + width: 1px; + height: 8px; + background: var(--border); + } + .quest-node:hover { background: #1e1e1e; } + .quest-node.done .quest-label { text-decoration: line-through; color: var(--done-text); } + .quest-node.done { background: var(--done-bg); } + .quest-label { flex: 1; font-size: 0.9rem; } + .quest-label a { color: var(--accent); font-size: 0.75rem; margin-left: 6px; } + .kappa-star { color: var(--kappa); font-size: 0.75rem; flex-shrink: 0; } + .cross-trader { + font-size: 0.75rem; + color: var(--muted); + font-style: italic; + flex-shrink: 0; } .toggle-btn { - background: #2a2a2a; - color: var(--text); + background: transparent; border: 1px solid #444; - border-radius: 6px; - padding: 4px 10px; + color: var(--muted); + border-radius: 4px; + padding: 2px 7px; cursor: pointer; - font-size: 0.85rem; - white-space: nowrap; + font-size: 0.75rem; + flex-shrink: 0; } - .quest-row.done .toggle-btn { - background: #1e3a1e; + .quest-node.done .toggle-btn { border-color: #3a6a3a; color: var(--done-text); } - nav { margin-bottom: 20px; } - nav a { color: var(--accent); font-size: 0.9rem; } + + /* ── LIST VIEW ── + Classic file-manager tree: ├── and └── connectors. */ + .list-view .trader-body { padding: 4px 0; } + .list-tree { padding: 0; margin: 0; } + .list-item { display: flex; align-items: flex-start; } + .list-indent { display: flex; flex-shrink: 0; } + .list-indent-seg { width: 20px; flex-shrink: 0; position: relative; min-height: 30px; } + .list-indent-seg.vert::before { + content: ""; position: absolute; top: 0; bottom: 0; left: 9px; width: 1px; background: var(--line); + } + .list-indent-seg.tee::before { + content: ""; position: absolute; top: 0; bottom: 0; left: 9px; width: 1px; background: var(--line); + } + .list-indent-seg.tee::after { + content: ""; position: absolute; top: 50%; left: 9px; width: 11px; height: 1px; background: var(--line); + } + .list-indent-seg.elbow::before { + content: ""; position: absolute; top: 0; bottom: 50%; left: 9px; width: 1px; background: var(--line); + } + .list-indent-seg.elbow::after { + content: ""; position: absolute; top: 50%; left: 9px; width: 11px; height: 1px; background: var(--line); + } + .list-indent-seg.blank {} + .list-row { + flex: 1; display: flex; align-items: center; gap: 6px; + padding: 4px 6px 4px 2px; border-radius: 4px; margin: 1px 0; + background: transparent; min-height: 30px; + } + .list-row:hover { background: #1a1a1a; } + .list-row.done .list-name { text-decoration: line-through; color: var(--done-text); } + .list-name { font-size: 0.85rem; flex: 1; } + .list-wiki { color: var(--accent); font-size: 0.72rem; text-decoration: none; flex-shrink: 0; } + .list-wiki:hover { text-decoration: underline; } + .list-row .kappa-star { font-size: 0.72rem; } + .list-row .cross-badge { font-size: 0.7rem; } + .list-row .toggle-btn { font-size: 0.72rem; } + .list-row.done .toggle-btn { border-color: #3a6a3a; color: var(--done-text); } + + .flow-view.hidden, .list-view.hidden { display: none; } @@ -117,30 +305,131 @@
- {% set ns = namespace(current_trader=None) %} - {% for quest in quests %} - {% if quest.trader != ns.current_trader %} - {% if ns.current_trader is not none %}{% endif %} -
-
{{ quest.trader }}
- {% set ns.current_trader = quest.trader %} - {% endif %} +
+ Flow + List + | + + +
-
- - {{ quest.name }} - {% if quest.wiki_link %} - wiki - {% endif %} - +
+ Required for Collector + Marked done + ← Trader Cross-trader dependency +
+ +{% macro render_node(qid, quest_by_id, children, visible, collector_prereqs, collector_id, is_root=False) %} +{% set q = quest_by_id[qid] %} +{% set visible_kids = [] %} +{% for cid in children.get(qid, []) %} + {% if cid in visible %}{% set _ = visible_kids.append(cid) %}{% endif %} +{% endfor %} +
+
+ {% if qid in collector_prereqs %}{% endif %} + + {{ q.name }} + {% if q.wiki_link %}wiki{% endif %} + + {% if qid != collector_id %} + {% endif %} +
+ {% if visible_kids %} +
+ {% for cid in visible_kids %} + {% set child = quest_by_id[cid] %} + {% if child.trader != q.trader %} +
+ {% if cid in collector_prereqs %}{% endif %} + + {{ child.name }} + {% if child.wiki_link %}wiki{% endif %} + + ← {{ child.trader }} + {% if cid != collector_id %} + + {% endif %} +
+ {% else %} + {{ render_node(cid, quest_by_id, children, visible, collector_prereqs, collector_id, false) }} + {% endif %} + {% endfor %} +
+ {% endif %} +
+{% endmacro %} + + {# ── FLOW VIEW ── #} +
+ {% for trader in traders %} + {% set roots = trader_roots[trader] %} + {% set total_trader = namespace(n=0) %} + {% set done_trader = namespace(n=0) %} + {% for qid in visible %} + {% if quest_by_id[qid].trader == trader and qid in collector_prereqs %} + {% set total_trader.n = total_trader.n + 1 %} + {% if quest_by_id[qid].done %}{% set done_trader.n = done_trader.n + 1 %}{% endif %} + {% endif %} + {% endfor %} + +
+
+ {{ trader }} + {{ done_trader.n }} / {{ total_trader.n }} + +
+
+ {% for root_id in roots %} + {{ render_node(root_id, quest_by_id, children, visible, collector_prereqs, collector_id, true) }} + {% endfor %} +
{% endfor %} - {% if ns.current_trader is not none %}
{% endif %} +
+ + {# ── LIST VIEW ── #} +
+ {% for trader in traders %} + {% set roots = trader_roots[trader] %} + {% set total_trader = namespace(n=0) %} + {% set done_trader = namespace(n=0) %} + {% for qid in visible %} + {% if quest_by_id[qid].trader == trader and qid in collector_prereqs %} + {% set total_trader.n = total_trader.n + 1 %} + {% if quest_by_id[qid].done %}{% set done_trader.n = done_trader.n + 1 %}{% endif %} + {% endif %} + {% endfor %} + +
+
+ {{ trader }} + {{ done_trader.n }} / {{ total_trader.n }} + +
+
+
+ {% for root_id in roots %} + {{ render_list_item(root_id, quest_by_id, children, visible, collector_prereqs, [], loop.last, collector_id) }} + {% endfor %} +
+
+
+ {% endfor %} +
+ diff --git a/templates/quests.html b/templates/quests.html index 9eaba34..15af0bb 100644 --- a/templates/quests.html +++ b/templates/quests.html @@ -1,3 +1,102 @@ +{# ══════════════════════════════════════════════ + MACROS — must come before first use in Jinja2 +══════════════════════════════════════════════ #} + +{# Flow view: depth-indented vertical chain with connector lines #} +{% macro render_chain(qid, quest_by_id, children, visible, collector_prereqs, depth) %} +{% set q = quest_by_id[qid] %} +{% set visible_kids = [] %} +{% for cid in children.get(qid, []) %} + {% if cid in visible %}{% set _ = visible_kids.append(cid) %}{% endif %} +{% endfor %} +{% if depth > 0 %}
{% endif %} +
+
+
+ {% if qid in collector_prereqs %}{% endif %} + {{ q.name }} + {% if q.wiki_link %}wiki{% endif %} + +
+
+
+{% for cid in visible_kids %} + {% set child = quest_by_id[cid] %} + {% if child.trader != q.trader %} +
+
+
+
+ {% if cid in collector_prereqs %}{% endif %} + {{ child.name }} + {% if child.wiki_link %}wiki{% endif %} + +
+
{{ child.trader }}
+
+
+ {% else %} + {{ render_chain(cid, quest_by_id, children, visible, collector_prereqs, depth + 1) }} + {% endif %} +{% endfor %} +{% endmacro %} + + +{# List view: indented tree with ├── / └── connector lines. + open_stack: list of booleans — True = ancestor has more siblings (draw vert), False = last (draw blank). + is_last: whether this node is the last sibling among its parent's children. #} +{% macro render_list_item(qid, quest_by_id, children, visible, collector_prereqs, open_stack, is_last) %} +{% set q = quest_by_id[qid] %} +{% set visible_kids = [] %} +{% for cid in children.get(qid, []) %} + {% if cid in visible %}{% set _ = visible_kids.append(cid) %}{% endif %} +{% endfor %} +
+
+ {% for open in open_stack %} +
+ {% endfor %} + {% if open_stack %} +
+ {% endif %} +
+
+ {% if qid in collector_prereqs %}{% endif %} + {{ q.name }} + {% if q.wiki_link %}wiki{% endif %} + +
+
+{% set child_stack = open_stack + [not is_last] %} +{% for cid in visible_kids %} + {% set child = quest_by_id[cid] %} + {% set child_last = loop.last %} + {% if child.trader != q.trader %} +
+
+ {% for open in child_stack %} +
+ {% endfor %} +
+
+
+ {% if cid in collector_prereqs %}{% endif %} + {{ child.name }} + {% if child.wiki_link %}wiki{% endif %} + {{ child.trader }} + +
+
+ {% else %} + {{ render_list_item(cid, quest_by_id, children, visible, collector_prereqs, child_stack, child_last) }} + {% endif %} +{% endfor %} +{% endmacro %} + @@ -14,115 +113,115 @@ --done-text: #6ec96e; --done-bg: #1a2a1a; --kappa: #f0c040; + --line: #333; } * { box-sizing: border-box; } - body { - font-family: sans-serif; - background: var(--bg); - color: var(--text); - margin: 0; - padding: 16px; - } - .page { max-width: 960px; margin: 0 auto; } + body { font-family: sans-serif; background: var(--bg); color: var(--text); margin: 0; padding: 16px; } + .page { max-width: 1100px; margin: 0 auto; } nav { margin-bottom: 16px; font-size: 0.9rem; } nav a { color: var(--accent); } h1 { margin: 0 0 4px; } - .toolbar { - display: flex; - align-items: center; - gap: 12px; - flex-wrap: wrap; - margin-bottom: 20px; - } - .filter-btn { - background: var(--panel); - border: 1px solid #444; - color: var(--text); - border-radius: 6px; - padding: 6px 14px; - cursor: pointer; - font-size: 0.9rem; - text-decoration: none; - } - .filter-btn.active { - border-color: var(--kappa); - color: var(--kappa); - } - .legend { - display: flex; - gap: 16px; - font-size: 0.8rem; - color: var(--muted); - flex-wrap: wrap; - } - .legend span { display: flex; align-items: center; gap: 5px; } - /* Trader section */ + /* toolbar */ + .toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 18px; } + .filter-btn { + background: var(--panel); border: 1px solid #444; color: var(--text); + border-radius: 6px; padding: 5px 14px; cursor: pointer; font-size: 0.85rem; text-decoration: none; + } + .filter-btn.active { border-color: var(--kappa); color: var(--kappa); } + .sep { color: var(--border); } + .legend { display: flex; gap: 14px; font-size: 0.78rem; color: var(--muted); flex-wrap: wrap; margin-left: auto; } + .legend span { display: flex; align-items: center; gap: 4px; } + + /* trader sections */ .trader-section { margin-bottom: 8px; } .trader-header { - display: flex; - align-items: center; - gap: 8px; - padding: 10px 8px; - background: var(--panel); - border: 1px solid var(--border); - border-radius: 6px; - cursor: pointer; - user-select: none; + display: flex; align-items: center; gap: 8px; padding: 9px 10px; + background: var(--panel); border: 1px solid var(--border); border-radius: 6px; + cursor: pointer; user-select: none; } .trader-header:hover { border-color: #444; } - .trader-name { - font-weight: bold; - font-size: 0.95rem; - flex: 1; - } + .trader-name { font-weight: bold; font-size: 0.9rem; flex: 1; } .trader-counts { font-size: 0.8rem; color: var(--muted); } - .chevron { color: var(--muted); font-size: 0.8rem; transition: transform 0.15s; } + .chevron { color: var(--muted); font-size: 0.75rem; transition: transform 0.15s; } .trader-section.collapsed .chevron { transform: rotate(-90deg); } - .trader-body { padding: 6px 0 6px 8px; } + .trader-body { padding: 4px 0; } .trader-section.collapsed .trader-body { display: none; } - /* Tree nodes */ - .tree-root { margin: 4px 0; } - .tree-children { - margin-left: 20px; - border-left: 1px solid var(--border); - padding-left: 10px; + /* ── FLOW VIEW ── + Quests rendered as a depth-indented vertical chain. + Parent → children flow top-to-bottom with indent + short vert connector. */ + .flow-view .trader-body { padding: 8px 0 4px; } + .fnode-connector { + width: 1px; height: 10px; background: var(--line); flex-shrink: 0; } - .quest-node { - display: flex; - align-items: center; - gap: 8px; - padding: 5px 6px; - border-radius: 4px; - margin: 2px 0; + .fnode-wrap { display: flex; flex-direction: column; align-items: flex-start; width: 100%; } + .fnode { + width: calc(100% - 8px); margin: 0 4px; padding: 5px 8px; + border-radius: 5px; background: #141820; border: 1px solid #1e2535; } - .quest-node:hover { background: #1e1e1e; } - .quest-node.done .quest-label { text-decoration: line-through; color: var(--done-text); } - .quest-node.done { background: var(--done-bg); } - .quest-label { flex: 1; font-size: 0.9rem; } - .quest-label a { color: var(--accent); font-size: 0.75rem; margin-left: 6px; } - .kappa-star { color: var(--kappa); font-size: 0.75rem; flex-shrink: 0; } - .cross-trader { - font-size: 0.75rem; - color: var(--muted); - font-style: italic; - flex-shrink: 0; + .fnode:hover { background: #1c2030; } + .fnode.done { background: var(--done-bg); border-color: #2a4a2a; } + .fnode.kappa-node { border-color: #5a4a10; } + .fnode-top { display: flex; align-items: center; gap: 5px; min-height: 20px; } + .fnode-name { font-size: 0.83rem; flex: 1; line-height: 1.3; word-break: break-word; } + .fnode.done .fnode-name { text-decoration: line-through; color: var(--done-text); } + .fnode-wiki { color: var(--accent); font-size: 0.7rem; text-decoration: none; flex-shrink: 0; } + .fnode-wiki:hover { text-decoration: underline; } + .fnode-meta { display: flex; align-items: center; gap: 5px; margin-top: 3px; } + .kappa-star { color: var(--kappa); font-size: 0.7rem; flex-shrink: 0; } + .cross-badge { + font-size: 0.65rem; color: var(--muted); font-style: italic; + background: #222; border-radius: 3px; padding: 1px 4px; } .toggle-btn { - background: transparent; - border: 1px solid #444; - color: var(--muted); - border-radius: 4px; - padding: 2px 7px; - cursor: pointer; - font-size: 0.75rem; - flex-shrink: 0; + background: transparent; border: 1px solid #444; color: var(--muted); + border-radius: 3px; padding: 1px 5px; cursor: pointer; font-size: 0.7rem; + flex-shrink: 0; margin-left: auto; } - .quest-node.done .toggle-btn { - border-color: #3a6a3a; - color: var(--done-text); + .fnode.done .toggle-btn { border-color: #3a6a3a; color: var(--done-text); } + + /* ── LIST VIEW ── + Classic file-manager tree: ├── and └── connectors. */ + .list-view .trader-body { padding: 4px 0; } + .list-tree { padding: 0; margin: 0; } + .list-item { display: flex; align-items: flex-start; } + .list-indent { display: flex; flex-shrink: 0; } + .list-indent-seg { width: 20px; flex-shrink: 0; position: relative; min-height: 30px; } + .list-indent-seg.vert::before { + content: ""; position: absolute; top: 0; bottom: 0; left: 9px; width: 1px; background: var(--line); } + .list-indent-seg.tee::before { + content: ""; position: absolute; top: 0; bottom: 0; left: 9px; width: 1px; background: var(--line); + } + .list-indent-seg.tee::after { + content: ""; position: absolute; top: 50%; left: 9px; width: 11px; height: 1px; background: var(--line); + } + .list-indent-seg.elbow::before { + content: ""; position: absolute; top: 0; bottom: 50%; left: 9px; width: 1px; background: var(--line); + } + .list-indent-seg.elbow::after { + content: ""; position: absolute; top: 50%; left: 9px; width: 11px; height: 1px; background: var(--line); + } + /* blank: spacer only, no line */ + .list-indent-seg.blank {} + + .list-row { + flex: 1; display: flex; align-items: center; gap: 6px; + padding: 4px 6px 4px 2px; border-radius: 4px; margin: 1px 0; + background: transparent; min-height: 30px; + } + .list-row:hover { background: #1a1a1a; } + .list-row.done .list-name { text-decoration: line-through; color: var(--done-text); } + .list-name { font-size: 0.85rem; flex: 1; } + .list-wiki { color: var(--accent); font-size: 0.72rem; text-decoration: none; flex-shrink: 0; } + .list-wiki:hover { text-decoration: underline; } + .list-row .kappa-star { font-size: 0.72rem; } + .list-row .cross-badge { font-size: 0.7rem; } + .list-row .toggle-btn { font-size: 0.72rem; } + .list-row.done .toggle-btn { border-color: #3a6a3a; color: var(--done-text); } + + .flow-view.hidden, .list-view.hidden { display: none; } @@ -131,109 +230,135 @@ ← Keys  |  Collector Checklist +  |  + Loadout Planner

Quest Trees

- All quests - ★ Collector only + All quests + ★ Collector only + + | + + Flow + List + + | + + + +
- Required for Collector - Marked done - ← Trader Cross-trader dependency + Collector req + Done + cross Other trader
-{% macro render_node(qid, quest_by_id, children, visible, collector_prereqs) %} -{% set q = quest_by_id[qid] %} -{% set visible_kids = [] %} -{% for cid in children.get(qid, []) %} - {% if cid in visible %}{% set _ = visible_kids.append(cid) %}{% endif %} -{% endfor %} -
-
- {% if qid in collector_prereqs %}{% endif %} - - {{ q.name }} - {% if q.wiki_link %}wiki{% endif %} - - -
- {% if visible_kids %} -
- {% for cid in visible_kids %} - {% set child = quest_by_id[cid] %} - {% if child.trader != q.trader %} -
- {% if cid in collector_prereqs %}{% endif %} - - {{ child.name }} - {% if child.wiki_link %}wiki{% endif %} - - ← {{ child.trader }} - -
- {% else %} - {{ render_node(cid, quest_by_id, children, visible, collector_prereqs) }} - {% endif %} - {% endfor %} -
- {% endif %} -
-{% endmacro %} - + {# ── FLOW VIEW ── #} +
{% for trader in traders %} {% set roots = trader_roots[trader] %} - {% set total_trader = namespace(n=0) %} - {% set done_trader = namespace(n=0) %} - {# count visible quests for this trader #} + {% set total_t = namespace(n=0) %} + {% set done_t = namespace(n=0) %} {% for qid in visible %} {% if quest_by_id[qid].trader == trader %} - {% set total_trader.n = total_trader.n + 1 %} - {% if quest_by_id[qid].done %}{% set done_trader.n = done_trader.n + 1 %}{% endif %} + {% set total_t.n = total_t.n + 1 %} + {% if quest_by_id[qid].done %}{% set done_t.n = done_t.n + 1 %}{% endif %} {% endif %} {% endfor %} - -
+
{{ trader }} - {{ done_trader.n }} / {{ total_trader.n }} + {{ done_t.n }} / {{ total_t.n }}
{% for root_id in roots %} - {{ render_node(root_id, quest_by_id, children, visible, collector_prereqs) }} + {{ render_chain(root_id, quest_by_id, children, visible, collector_prereqs, 0) }} {% endfor %}
{% endfor %} +
+ + {# ── LIST VIEW ── #} +
+ {% for trader in traders %} + {% set roots = trader_roots[trader] %} + {% set total_t = namespace(n=0) %} + {% set done_t = namespace(n=0) %} + {% for qid in visible %} + {% if quest_by_id[qid].trader == trader %} + {% set total_t.n = total_t.n + 1 %} + {% if quest_by_id[qid].done %}{% set done_t.n = done_t.n + 1 %}{% endif %} + {% endif %} + {% endfor %} +
+
+ {{ trader }} + {{ done_t.n }} / {{ total_t.n }} + +
+
+
+ {% for root_id in roots %} + {{ render_list_item(root_id, quest_by_id, children, visible, collector_prereqs, [], loop.last) }} + {% endfor %} +
+
+
+ {% endfor %} +