from flask import Flask, render_template, request, redirect, url_for, jsonify import sqlite3 app = Flask(__name__) DB_PATH = "tarkov.db" def get_db(): conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row return conn @app.route("/") def index(): conn = get_db() maps = conn.execute(""" SELECT id, name FROM maps ORDER BY name """).fetchall() map_filter = request.args.get("map_id", type=int) sort = request.args.get("sort", "priority_desc") show = request.args.get("show", "all") key_map_rows = conn.execute(""" SELECT key_id, map_id FROM key_maps """).fetchall() key_maps = {} for row in key_map_rows: key_maps.setdefault(row["key_id"], set()).add(row["map_id"]) key_query = """ SELECT k.id, k.name, k.icon_url, k.grid_image_url, k.wiki_url, r.priority, r.reason, COALESCE(r.used_in_quest, 0) AS used_in_quest FROM keys k """ params = [] if map_filter: key_query += """ JOIN key_maps kmf ON k.id = kmf.key_id AND kmf.map_id = ? """ params.append(map_filter) key_query += """ LEFT JOIN key_ratings r ON k.id = r.key_id """ if show == "rated": key_query += " WHERE r.priority IS NOT NULL " elif show == "unrated": key_query += " WHERE r.priority IS NULL " elif show == "quest": key_query += " WHERE COALESCE(r.used_in_quest, 0) = 1 " if sort == "name_asc": order_by = "k.name ASC" elif sort == "name_desc": order_by = "k.name DESC" elif sort == "priority_asc": order_by = "CASE WHEN r.priority IS NULL THEN 1 ELSE 0 END, r.priority ASC, k.name" else: order_by = "CASE WHEN r.priority IS NULL THEN 1 ELSE 0 END, r.priority DESC, k.name" key_query += f" ORDER BY {order_by} " keys = conn.execute(key_query, params).fetchall() conn.close() key_maps = {k: sorted(v) for k, v in key_maps.items()} return render_template( "index.html", keys=keys, maps=maps, key_maps=key_maps, map_filter=map_filter, sort=sort, show=show, ) @app.route("/rate", methods=["POST"]) def rate_key(): key_id = request.form["key_id"] priority = request.form.get("priority") if priority == "": priority = None reason = request.form.get("reason", "") used_in_quest = 1 if request.form.get("used_in_quest") == "on" else 0 map_filter = request.form.get("map_id") sort = request.form.get("sort") show = request.form.get("show") map_ids = [] for value in request.form.getlist("map_ids"): try: map_ids.append(int(value)) except ValueError: continue conn = get_db() conn.execute(""" INSERT INTO key_ratings (key_id, priority, reason, used_in_quest) VALUES (?, ?, ?, ?) ON CONFLICT(key_id) DO UPDATE SET priority = excluded.priority, reason = excluded.reason, used_in_quest = excluded.used_in_quest, updated_at = CURRENT_TIMESTAMP """, (key_id, priority, reason, used_in_quest)) conn.execute("DELETE FROM key_maps WHERE key_id = ?", (key_id,)) if map_ids: conn.executemany( "INSERT OR IGNORE INTO key_maps (key_id, map_id) VALUES (?, ?)", [(key_id, map_id) for map_id in map_ids], ) conn.commit() conn.close() redirect_args = {} if map_filter: redirect_args["map_id"] = map_filter if sort: redirect_args["sort"] = sort if show: redirect_args["show"] = show base_url = url_for("index", **redirect_args) return redirect(f"{base_url}#key-{key_id}") def _update_key(conn, key_id, priority, reason, used_in_quest, map_ids): conn.execute(""" INSERT INTO key_ratings (key_id, priority, reason, used_in_quest) VALUES (?, ?, ?, ?) ON CONFLICT(key_id) DO UPDATE SET priority = excluded.priority, reason = excluded.reason, used_in_quest = excluded.used_in_quest, updated_at = CURRENT_TIMESTAMP """, (key_id, priority, reason, used_in_quest)) conn.execute("DELETE FROM key_maps WHERE key_id = ?", (key_id,)) if map_ids: conn.executemany( "INSERT OR IGNORE INTO key_maps (key_id, map_id) VALUES (?, ?)", [(key_id, map_id) for map_id in map_ids], ) @app.route("/rate_all", methods=["POST"]) def rate_all(): key_ids = request.form.getlist("key_ids") save_one = request.form.get("save_one") map_filter = request.form.get("map_id") sort = request.form.get("sort") show = request.form.get("show") if save_one: key_ids = [save_one] conn = get_db() for key_id in key_ids: priority = request.form.get(f"priority_{key_id}") if priority is None: continue if priority == "": priority = None reason = request.form.get(f"reason_{key_id}", "") used_in_quest = 1 if request.form.get(f"used_in_quest_{key_id}") == "on" else 0 map_ids = [] for value in request.form.getlist(f"map_ids_{key_id}"): try: map_ids.append(int(value)) except ValueError: continue _update_key(conn, key_id, priority, reason, used_in_quest, map_ids) conn.commit() conn.close() redirect_args = {} if map_filter: redirect_args["map_id"] = map_filter if sort: redirect_args["sort"] = sort if show: redirect_args["show"] = show base_url = url_for("index", **redirect_args) if save_one: return redirect(f"{base_url}#key-{save_one}") return redirect(base_url) @app.route("/quests") def quests(): conn = get_db() only_collector = request.args.get("collector") == "1" # 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 collector_row = conn.execute("SELECT id FROM quests WHERE name = 'Collector'").fetchone() collector_prereqs = set() if collector_row: 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 quest_id FROM deps """, (collector_row["id"],)).fetchall() collector_prereqs = {r[0] for r in rows} conn.close() # 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 "") # Filter to collector-only if requested if only_collector: visible = collector_prereqs | {collector_row["id"]} else: visible = set(quest_by_id.keys()) # 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()) return render_template( "quests.html", quest_by_id=quest_by_id, children=children, trader_roots=trader_roots, traders=traders, visible=visible, collector_prereqs=collector_prereqs, only_collector=only_collector, ) @app.route("/collector") def collector(): conn = get_db() collector = conn.execute( "SELECT id FROM quests WHERE name = 'Collector'" ).fetchone() if not 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(""" 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 """, (collector["id"],)).fetchall() 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) @app.route("/collector/toggle", methods=["POST"]) def collector_toggle(): quest_id = request.form["quest_id"] done = 1 if request.form.get("done") == "1" else 0 conn = get_db() conn.execute(""" INSERT INTO quest_progress (quest_id, done) VALUES (?, ?) ON CONFLICT(quest_id) DO UPDATE SET done = excluded.done """, (quest_id, done)) conn.commit() conn.close() return jsonify({"quest_id": quest_id, "done": done}) # --- Loadout planner helpers --- # Known user-facing slot filters: (label, slot_nameid) LOADOUT_SLOT_FILTERS = [ ("Suppressor", "mod_muzzle"), ("Scope", "mod_scope"), ("Flashlight", "mod_tactical"), ("Stock", "mod_stock"), ("Foregrip", "mod_foregrip"), ] def _sort_col(sort): return { "weight_asc": "weight_kg ASC NULLS LAST", "weight_desc": "weight_kg DESC NULLS LAST", "name_asc": "name ASC", "name_desc": "name DESC", "class_desc": "armor_class DESC NULLS LAST, weight_kg ASC NULLS LAST", "class_asc": "armor_class ASC NULLS LAST, weight_kg ASC NULLS LAST", "capacity_desc": "capacity DESC NULLS LAST, weight_kg ASC NULLS LAST", "capacity_asc": "capacity ASC NULLS LAST, weight_kg ASC NULLS LAST", }.get(sort, "weight_kg ASC NULLS LAST") @app.route("/loadout") def loadout(): conn = get_db() tab = request.args.get("tab", "guns") sort = request.args.get("sort", "weight_asc") guns = armor = helmets = headwear = backpacks = rigs = [] builder_guns = builder_armor = builder_helmets = builder_rigs = builder_backpacks = [] requires = request.args.getlist("requires") # list of slot_nameids that must exist min_class = request.args.get("min_class", 0, type=int) min_capacity = request.args.get("min_capacity", 0, type=int) sort_frag = _sort_col(sort) if tab == "guns": if requires: placeholders = ",".join("?" * len(requires)) # Gun must have ALL required slots; compute lightest build weight guns = conn.execute(f""" SELECT g.*, (g.weight_kg + COALESCE(( SELECT SUM(s.min_w) FROM ( SELECT gs.slot_id, MIN(m.weight_kg) AS min_w FROM gun_slots gs JOIN gun_slot_items gsi ON gsi.gun_id = gs.gun_id AND gsi.slot_id = gs.slot_id JOIN gear_items m ON m.id = gsi.item_id WHERE gs.gun_id = g.id AND gs.slot_nameid IN ({placeholders}) AND m.weight_kg IS NOT NULL GROUP BY gs.slot_id ) s ), 0.0)) AS lightest_build_weight FROM gear_items g WHERE g.category = 'gun' AND ( SELECT COUNT(DISTINCT gs2.slot_nameid) FROM gun_slots gs2 WHERE gs2.gun_id = g.id AND gs2.slot_nameid IN ({placeholders}) ) = ? ORDER BY lightest_build_weight ASC NULLS LAST """, requires + requires + [len(requires)]).fetchall() else: guns = conn.execute(f""" SELECT *, weight_kg AS lightest_build_weight FROM gear_items WHERE category = 'gun' ORDER BY {sort_frag} """).fetchall() elif tab == "armor": armor = conn.execute(f""" SELECT * FROM gear_items WHERE category = 'armor' AND (? = 0 OR armor_class >= ?) ORDER BY {sort_frag} """, (min_class, min_class)).fetchall() elif tab == "helmets": helmets = conn.execute(f""" SELECT * FROM gear_items WHERE category = 'helmet' AND (? = 0 OR armor_class >= ?) ORDER BY {sort_frag} """, (min_class, min_class)).fetchall() elif tab == "headwear": headwear = conn.execute(f""" SELECT * FROM gear_items WHERE category = 'headwear' AND (? = 0 OR armor_class >= ?) ORDER BY {sort_frag} """, (min_class, min_class)).fetchall() elif tab == "backpacks": backpacks = conn.execute(f""" SELECT * FROM gear_items WHERE category = 'backpack' AND (? = 0 OR capacity >= ?) ORDER BY {sort_frag} """, (min_capacity, min_capacity)).fetchall() elif tab == "rigs": rigs = conn.execute(f""" SELECT * FROM gear_items WHERE category = 'rig' AND (? = 0 OR capacity >= ?) AND (? = 0 OR armor_class >= ?) ORDER BY {sort_frag} """, (min_capacity, min_capacity, min_class, min_class)).fetchall() elif tab == "builder": builder_guns = conn.execute("SELECT id, name, weight_kg FROM gear_items WHERE category='gun' ORDER BY name").fetchall() builder_armor = conn.execute("SELECT id, name, weight_kg FROM gear_items WHERE category='armor' ORDER BY name").fetchall() builder_helmets = conn.execute("SELECT id, name, weight_kg FROM gear_items WHERE category='helmet' ORDER BY name").fetchall() builder_rigs = conn.execute("SELECT id, name, weight_kg FROM gear_items WHERE category='rig' ORDER BY name").fetchall() builder_backpacks = conn.execute("SELECT id, name, weight_kg FROM gear_items WHERE category='backpack' ORDER BY name").fetchall() conn.close() return render_template( "loadout.html", tab=tab, sort=sort, guns=guns, armor=armor, helmets=helmets, headwear=headwear, backpacks=backpacks, rigs=rigs, slot_filters=LOADOUT_SLOT_FILTERS, requires=requires, min_class=min_class, min_capacity=min_capacity, builder_guns=builder_guns, builder_armor=builder_armor, builder_helmets=builder_helmets, builder_rigs=builder_rigs, builder_backpacks=builder_backpacks, ) @app.route("/loadout/gun/") def gun_detail(gun_id): conn = get_db() gun = conn.execute( "SELECT * FROM gear_items WHERE id = ? AND category = 'gun'", (gun_id,) ).fetchone() if not gun: conn.close() return "Gun not found.", 404 # All slots for this gun, with every compatible mod sorted by weight slots_raw = conn.execute(""" SELECT gs.slot_id, gs.slot_name, gs.slot_nameid, gs.required, m.id AS mod_id, m.name AS mod_name, m.short_name AS mod_short, m.weight_kg, m.grid_image_url, m.wiki_url, m.mod_type FROM gun_slots gs LEFT JOIN gun_slot_items gsi ON gsi.gun_id = gs.gun_id AND gsi.slot_id = gs.slot_id LEFT JOIN gear_items m ON m.id = gsi.item_id WHERE gs.gun_id = ? ORDER BY gs.slot_name, m.weight_kg ASC NULLS LAST """, (gun_id,)).fetchall() # Group by slot slots = {} slot_order = [] for row in slots_raw: sid = row["slot_id"] if sid not in slots: slots[sid] = { "slot_id": sid, "slot_name": row["slot_name"], "slot_nameid": row["slot_nameid"], "required": row["required"], "mods": [], } slot_order.append(sid) if row["mod_id"]: slots[sid]["mods"].append(dict(row)) # Key slots to show at top (highlighted) KEY_SLOTS = {"mod_muzzle", "mod_magazine"} ordered_slots = [slots[s] for s in slot_order] key_slots = [s for s in ordered_slots if s["slot_nameid"] in KEY_SLOTS] other_slots = [s for s in ordered_slots if s["slot_nameid"] not in KEY_SLOTS] # Lightest total (base + lightest per slot) lightest_total = (gun["weight_kg"] or 0) + sum( s["mods"][0]["weight_kg"] for s in ordered_slots if s["mods"] and s["mods"][0]["weight_kg"] is not None ) conn.close() return render_template( "gun_detail.html", gun=gun, key_slots=key_slots, other_slots=other_slots, lightest_total=lightest_total, ) @app.route("/loadout/gun//slots.json") def gun_slots_json(gun_id): """Returns slot summary for the expandable row (lightest mod per slot only).""" conn = get_db() rows = conn.execute(""" SELECT gs.slot_name, gs.slot_nameid, gs.required, m.name AS mod_name, m.weight_kg FROM gun_slots gs LEFT JOIN ( SELECT gsi.gun_id, gsi.slot_id, m2.name, m2.weight_kg FROM gun_slot_items gsi JOIN gear_items m2 ON m2.id = gsi.item_id WHERE gsi.gun_id = ? AND m2.weight_kg = ( SELECT MIN(m3.weight_kg) FROM gun_slot_items gsi3 JOIN gear_items m3 ON m3.id = gsi3.item_id WHERE gsi3.gun_id = gsi.gun_id AND gsi3.slot_id = gsi.slot_id AND m3.weight_kg IS NOT NULL ) GROUP BY gsi.slot_id ) m ON m.gun_id = gs.gun_id AND m.slot_id = gs.slot_id WHERE gs.gun_id = ? ORDER BY gs.slot_name """, (gun_id, gun_id)).fetchall() conn.close() KEY_SLOTS = {"mod_muzzle", "mod_magazine"} result = [ { "slot_name": r["slot_name"], "slot_nameid": r["slot_nameid"], "required": r["required"], "mod_name": r["mod_name"], "weight_kg": r["weight_kg"], "key": r["slot_nameid"] in KEY_SLOTS, } for r in rows ] return jsonify(result) @app.route("/loadout/save-build", methods=["POST"]) def save_build(): data = request.get_json() or {} name = (data.get("name") or "My Build").strip() or "My Build" gun_id = data.get("gun_id") or None armor_id = data.get("armor_id") or None helmet_id = data.get("helmet_id") or None rig_id = data.get("rig_id") or None backpack_id = data.get("backpack_id") or None notes = data.get("notes", "") conn = get_db() cur = conn.execute(""" INSERT INTO saved_builds (name, gun_id, armor_id, helmet_id, rig_id, backpack_id, notes) VALUES (?, ?, ?, ?, ?, ?, ?) """, (name, gun_id, armor_id, helmet_id, rig_id, backpack_id, notes)) build_id = cur.lastrowid conn.commit() conn.close() return jsonify({"build_id": build_id, "name": name}) if __name__ == "__main__": app.run(host="0.0.0.0", port=5000, debug=True)