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 %}
-
-
- {% set ns.current_trader = quest.trader %}
- {% endif %}
+
-
-
- {{ 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 %}
+
+
+
+
+ {% 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 %}
+
+
+
+
+
+ {% 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
-{% 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 %}
-
-
+
{% 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 %}
+
+
+
+
+ {% for root_id in roots %}
+ {{ render_list_item(root_id, quest_by_id, children, visible, collector_prereqs, [], loop.last) }}
+ {% endfor %}
+
+
+
+ {% endfor %}
+