diff --git a/app.py b/app.py index e6eb38a..832a0ba 100644 --- a/app.py +++ b/app.py @@ -1,4 +1,4 @@ -from flask import Flask, render_template, request, redirect, url_for +from flask import Flask, render_template, request, redirect, url_for, jsonify import sqlite3 app = Flask(__name__) @@ -194,6 +194,85 @@ def rate_all(): 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() @@ -205,7 +284,8 @@ def collector(): conn.close() return "Run import_quests.py first to populate quest data.", 503 - # Recursive CTE to get all transitive prerequisites + # 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 = ? @@ -218,6 +298,12 @@ def collector(): 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() @@ -238,7 +324,273 @@ def collector_toggle(): """, (quest_id, done)) conn.commit() conn.close() - return redirect(url_for("collector")) + 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__": diff --git a/import_gear.py b/import_gear.py new file mode 100644 index 0000000..405658a --- /dev/null +++ b/import_gear.py @@ -0,0 +1,535 @@ +import requests +import sqlite3 +import sys + +DB_PATH = "tarkov.db" +API_URL = "https://api.tarkov.dev/graphql" + +# Slot nameId patterns that identify what type of mod goes in a slot +SLOT_TYPE_MAP = { + "mod_muzzle": "suppressor", + "mod_scope": "scope", + "mod_tactical": "flashlight", + "mod_tactical_001": "flashlight", + "mod_tactical_002": "flashlight", + "mod_tactical_003": "flashlight", + "mod_stock": "stock", + "mod_stock_000": "stock", + "mod_stock_001": "stock", + "mod_pistol_grip": "grip", + "mod_grip": "foregrip", + "mod_foregrip": "foregrip", + "mod_magazine": "magazine", + "mod_barrel": "barrel", + "mod_gas_block": "gas_block", + "mod_handguard": "handguard", + "mod_launcher": "launcher", + "mod_bipod": "bipod", +} + +# Fetch weapons separately due to large slots/allowedItems payload +GRAPHQL_QUERY_WEAPONS = """ +{ + weapons: items(types: [gun]) { + id name shortName weight gridImageLink wikiLink + properties { + ... on ItemPropertiesWeapon { + caliber + fireRate + ergonomics + recoilVertical + defaultWeight + slots { + id + name + nameId + required + filters { + allowedItems { id } + } + } + } + } + } +} +""" + +GRAPHQL_QUERY_GEAR = """ +{ + armor: items(types: [armor]) { + id name shortName weight gridImageLink wikiLink + properties { + ... on ItemPropertiesArmor { + class + durability + material { name } + zones + } + } + } + + helmets: items(types: [helmet]) { + id name shortName weight gridImageLink wikiLink + properties { + ... on ItemPropertiesHelmet { + class + durability + material { name } + headZones + deafening + } + } + } + + wearables: items(types: [wearable]) { + id name shortName weight gridImageLink wikiLink + properties { + ... on ItemPropertiesHelmet { + class + durability + material { name } + headZones + deafening + } + } + } + + backpacks: items(types: [backpack]) { + id name shortName weight gridImageLink wikiLink + properties { + ... on ItemPropertiesBackpack { + capacity + grids { width height } + } + } + } + + rigs: items(types: [rig]) { + id name shortName weight gridImageLink wikiLink + properties { + ... on ItemPropertiesChestRig { + capacity + class + durability + zones + } + } + } + + suppressors: items(types: [suppressor]) { + id name shortName weight gridImageLink wikiLink + } + + mods: items(types: [mods]) { + id name shortName weight gridImageLink wikiLink + } +} +""" + + +def gql(query, label="query"): + response = requests.post( + API_URL, + json={"query": query}, + timeout=90 + ) + response.raise_for_status() + data = response.json() + if "errors" in data: + raise RuntimeError(f"{label} errors: {data['errors']}") + return data["data"] + + +def upsert_item(cursor, item_id, name, short_name, category, weight, + grid_image_url, wiki_url, + caliber=None, fire_rate=None, ergonomics=None, recoil_vertical=None, + default_weight=None, armor_class=None, durability=None, material=None, + zones=None, head_zones=None, deafening=None, capacity=None, mod_type=None): + cursor.execute("SELECT id FROM gear_items WHERE api_id = ?", (item_id,)) + existing = cursor.fetchone() + if existing: + cursor.execute(""" + UPDATE gear_items + SET name=?, short_name=?, category=?, weight_kg=?, grid_image_url=?, wiki_url=?, + caliber=?, fire_rate=?, ergonomics=?, recoil_vertical=?, default_weight=?, + armor_class=?, durability=?, material=?, zones=?, head_zones=?, deafening=?, + capacity=?, mod_type=?, updated_at=CURRENT_TIMESTAMP + WHERE api_id=? + """, (name, short_name, category, weight, grid_image_url, wiki_url, + caliber, fire_rate, ergonomics, recoil_vertical, default_weight, + armor_class, durability, material, zones, head_zones, deafening, + capacity, mod_type, item_id)) + return "updated" + else: + cursor.execute(""" + INSERT INTO gear_items + (id, api_id, name, short_name, category, weight_kg, grid_image_url, wiki_url, + caliber, fire_rate, ergonomics, recoil_vertical, default_weight, + armor_class, durability, material, zones, head_zones, deafening, + capacity, mod_type) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + """, (item_id, item_id, name, short_name, category, weight, grid_image_url, wiki_url, + caliber, fire_rate, ergonomics, recoil_vertical, default_weight, + armor_class, durability, material, zones, head_zones, deafening, + capacity, mod_type)) + return "inserted" + + +def import_weapons(conn, weapons): + cursor = conn.cursor() + counts = {"inserted": 0, "updated": 0, "skipped": 0} + # Collect slot data for phase 2 + slot_data = {} # gun_id -> list of slot dicts + + for w in weapons: + item_id = w.get("id") + name = w.get("name") + if not item_id or not name: + counts["skipped"] += 1 + continue + + props = w.get("properties") or {} + result = upsert_item( + cursor, item_id, name, w.get("shortName"), "gun", w.get("weight"), + w.get("gridImageLink"), w.get("wikiLink"), + caliber=props.get("caliber"), + fire_rate=props.get("fireRate"), + ergonomics=props.get("ergonomics"), + recoil_vertical=props.get("recoilVertical"), + default_weight=props.get("defaultWeight"), + ) + counts[result] += 1 + + slots = props.get("slots") or [] + if slots: + slot_data[item_id] = slots + + conn.commit() + return counts, slot_data + + +def import_armor(conn, items): + cursor = conn.cursor() + counts = {"inserted": 0, "updated": 0, "skipped": 0} + for item in items: + item_id = item.get("id") + name = item.get("name") + if not item_id or not name: + counts["skipped"] += 1 + continue + props = item.get("properties") or {} + material = (props.get("material") or {}).get("name") + zones = ",".join(props.get("zones") or []) or None + result = upsert_item( + cursor, item_id, name, item.get("shortName"), "armor", item.get("weight"), + item.get("gridImageLink"), item.get("wikiLink"), + armor_class=props.get("class"), + durability=props.get("durability"), + material=material, + zones=zones, + ) + counts[result] += 1 + conn.commit() + return counts + + +def import_helmets(conn, items): + cursor = conn.cursor() + counts = {"inserted": 0, "updated": 0, "skipped": 0} + for item in items: + item_id = item.get("id") + name = item.get("name") + if not item_id or not name: + counts["skipped"] += 1 + continue + props = item.get("properties") or {} + material = (props.get("material") or {}).get("name") + head_zones_list = props.get("headZones") or [] + head_zones = ",".join(head_zones_list) or None + # True helmets cover the top of the head; face masks etc. go to 'headwear' + category = "helmet" if any("Top of the head" in z for z in head_zones_list) else "headwear" + result = upsert_item( + cursor, item_id, name, item.get("shortName"), category, item.get("weight"), + item.get("gridImageLink"), item.get("wikiLink"), + armor_class=props.get("class"), + durability=props.get("durability"), + material=material, + head_zones=head_zones, + deafening=props.get("deafening"), + ) + counts[result] += 1 + conn.commit() + return counts + + +def import_backpacks(conn, items): + cursor = conn.cursor() + counts = {"inserted": 0, "updated": 0, "skipped": 0} + for item in items: + item_id = item.get("id") + name = item.get("name") + if not item_id or not name: + counts["skipped"] += 1 + continue + props = item.get("properties") or {} + capacity = props.get("capacity") + if capacity is None: + capacity = sum( + g.get("width", 0) * g.get("height", 0) + for g in (props.get("grids") or []) + ) or None + result = upsert_item( + cursor, item_id, name, item.get("shortName"), "backpack", item.get("weight"), + item.get("gridImageLink"), item.get("wikiLink"), + capacity=capacity, + ) + counts[result] += 1 + conn.commit() + return counts + + +def import_rigs(conn, items): + cursor = conn.cursor() + counts = {"inserted": 0, "updated": 0, "skipped": 0} + for item in items: + item_id = item.get("id") + name = item.get("name") + if not item_id or not name: + counts["skipped"] += 1 + continue + props = item.get("properties") or {} + zones = ",".join(props.get("zones") or []) or None + result = upsert_item( + cursor, item_id, name, item.get("shortName"), "rig", item.get("weight"), + item.get("gridImageLink"), item.get("wikiLink"), + armor_class=props.get("class"), + durability=props.get("durability"), + capacity=props.get("capacity"), + zones=zones, + ) + counts[result] += 1 + conn.commit() + return counts + + +def import_suppressors(conn, items): + cursor = conn.cursor() + counts = {"inserted": 0, "updated": 0, "skipped": 0} + for item in items: + item_id = item.get("id") + name = item.get("name") + if not item_id or not name: + counts["skipped"] += 1 + continue + result = upsert_item( + cursor, item_id, name, item.get("shortName"), "mod", item.get("weight"), + item.get("gridImageLink"), item.get("wikiLink"), + mod_type="suppressor", + ) + counts[result] += 1 + conn.commit() + return counts + + +def import_mods(conn, items): + """Import generic weapon mods (scopes, grips, stocks, barrels, etc.)""" + cursor = conn.cursor() + counts = {"inserted": 0, "updated": 0, "skipped": 0} + for item in items: + item_id = item.get("id") + name = item.get("name") + if not item_id or not name: + counts["skipped"] += 1 + continue + # Don't overwrite suppressors already imported with their mod_type + cursor.execute("SELECT id FROM gear_items WHERE api_id = ?", (item_id,)) + existing = cursor.fetchone() + if existing: + # Only update weight/image if item already exists — don't overwrite mod_type + cursor.execute(""" + UPDATE gear_items + SET name=?, short_name=?, weight_kg=?, grid_image_url=?, wiki_url=?, + updated_at=CURRENT_TIMESTAMP + WHERE api_id=? + """, (name, item.get("shortName"), item.get("weight"), + item.get("gridImageLink"), item.get("wikiLink"), item_id)) + counts["updated"] += 1 + else: + result = upsert_item( + cursor, item_id, name, item.get("shortName"), "mod", item.get("weight"), + item.get("gridImageLink"), item.get("wikiLink"), + ) + counts[result] += 1 + conn.commit() + return counts + + +def import_slots(conn, slot_data): + """ + Phase 2: insert gun_slots and gun_slot_items rows. + slot_data: { gun_id: [slot_dict, ...] } + Only inserts gun_slot_items rows where item_id exists in gear_items. + """ + cursor = conn.cursor() + slots_inserted = 0 + slot_items_inserted = 0 + slot_items_skipped = 0 + + # Build set of known item IDs for fast lookup + known_ids = {row[0] for row in cursor.execute("SELECT id FROM gear_items").fetchall()} + + for gun_id, slots in slot_data.items(): + for slot in slots: + slot_id = slot.get("id") + slot_name = slot.get("name") or "" + slot_nameid = slot.get("nameId") or "" + required = 1 if slot.get("required") else 0 + + if not slot_id: + continue + + cursor.execute(""" + INSERT OR REPLACE INTO gun_slots (gun_id, slot_id, slot_name, slot_nameid, required) + VALUES (?, ?, ?, ?, ?) + """, (gun_id, slot_id, slot_name, slot_nameid, required)) + slots_inserted += 1 + + filters = slot.get("filters") or {} + allowed_items = filters.get("allowedItems") or [] + for allowed in allowed_items: + item_id = allowed.get("id") + if not item_id or item_id not in known_ids: + slot_items_skipped += 1 + continue + cursor.execute(""" + INSERT OR IGNORE INTO gun_slot_items (gun_id, slot_id, item_id) + VALUES (?, ?, ?) + """, (gun_id, slot_id, item_id)) + slot_items_inserted += 1 + + conn.commit() + return slots_inserted, slot_items_inserted, slot_items_skipped + + +def main(): + print("=== OnlyScavs Gear Import ===") + + # Phase 1a: fetch weapons (separate query — large payload) + print("\nFetching weapons from tarkov.dev...") + try: + weapon_data = gql(GRAPHQL_QUERY_WEAPONS, "weapons") + except Exception as e: + print(f"ERROR fetching weapons: {e}") + sys.exit(1) + weapons = weapon_data.get("weapons", []) + print(f" Fetched {len(weapons)} weapons") + + # Phase 1b: fetch other gear + print("Fetching armor, helmets, backpacks, rigs, suppressors...") + try: + gear_data = gql(GRAPHQL_QUERY_GEAR, "gear") + except Exception as e: + print(f"ERROR fetching gear: {e}") + sys.exit(1) + + armor = gear_data.get("armor", []) + helmets = gear_data.get("helmets", []) + backpacks = gear_data.get("backpacks", []) + rigs = gear_data.get("rigs", []) + suppressors = gear_data.get("suppressors", []) + mods = gear_data.get("mods", []) + + # Wearables: only keep those whose properties resolved to ItemPropertiesHelmet + # (i.e. they have 'class' or 'headZones' set — not decorative items) + wearables_raw = gear_data.get("wearables", []) + # Known helmet IDs from the main helmet query — skip duplicates + helmet_ids = {h["id"] for h in helmets} + wearable_helmets = [ + w for w in wearables_raw + if w["id"] not in helmet_ids + and isinstance(w.get("properties"), dict) + and (w["properties"].get("class") or w["properties"].get("headZones")) + ] + print(f" armor={len(armor)}, helmets={len(helmets)}, wearable_helmets={len(wearable_helmets)}, " + f"backpacks={len(backpacks)}, rigs={len(rigs)}, suppressors={len(suppressors)}, mods={len(mods)}") + + # Phase 2: insert into DB + conn = sqlite3.connect(DB_PATH) + conn.execute("PRAGMA foreign_keys = ON") + + try: + print("\nImporting weapons...") + wcounts, slot_data = import_weapons(conn, weapons) + print(f" guns: +{wcounts['inserted']} inserted, ~{wcounts['updated']} updated, -{wcounts['skipped']} skipped") + + print("Importing armor...") + c = import_armor(conn, armor) + print(f" armor: +{c['inserted']} inserted, ~{c['updated']} updated, -{c['skipped']} skipped") + + print("Importing helmets...") + c = import_helmets(conn, helmets) + print(f" helmets: +{c['inserted']} inserted, ~{c['updated']} updated, -{c['skipped']} skipped") + + print("Importing wearable helmets (missing from helmet type)...") + c = import_helmets(conn, wearable_helmets) + print(f" wearable helmets: +{c['inserted']} inserted, ~{c['updated']} updated, -{c['skipped']} skipped") + + print("Importing backpacks...") + c = import_backpacks(conn, backpacks) + print(f" backpacks: +{c['inserted']} inserted, ~{c['updated']} updated, -{c['skipped']} skipped") + + print("Importing rigs...") + c = import_rigs(conn, rigs) + print(f" rigs: +{c['inserted']} inserted, ~{c['updated']} updated, -{c['skipped']} skipped") + + print("Importing suppressors...") + c = import_suppressors(conn, suppressors) + print(f" suppressors: +{c['inserted']} inserted, ~{c['updated']} updated, -{c['skipped']} skipped") + + print("Importing mods (scopes, grips, stocks, barrels, etc.)...") + c = import_mods(conn, mods) + print(f" mods: +{c['inserted']} inserted, ~{c['updated']} updated, -{c['skipped']} skipped") + + # Phase 3: gun slots (needs all items in DB first) + print("Importing gun slots and compatibility data...") + slots_ins, slot_items_ins, slot_items_skip = import_slots(conn, slot_data) + print(f" gun_slots: {slots_ins} rows") + print(f" gun_slot_items: {slot_items_ins} rows inserted, {slot_items_skip} skipped (item not in DB)") + + # Phase 4: classify mod_type for mods based on which slots they appear in + print("Classifying mod types from slot data...") + cursor = conn.cursor() + updated_mod_types = 0 + for slot_nameid, mod_type in SLOT_TYPE_MAP.items(): + result = cursor.execute(""" + UPDATE gear_items + SET mod_type = ? + WHERE id IN ( + SELECT gsi.item_id + FROM gun_slot_items gsi + JOIN gun_slots gs ON gs.gun_id = gsi.gun_id AND gs.slot_id = gsi.slot_id + WHERE gs.slot_nameid = ? + ) + AND category = 'mod' + AND (mod_type IS NULL OR mod_type != 'suppressor') + """, (mod_type, slot_nameid)) + updated_mod_types += result.rowcount + conn.commit() + print(f" mod_type set on {updated_mod_types} mod items") + + except Exception as e: + conn.rollback() + print(f"ERROR: Database operation failed — {e}") + import traceback + traceback.print_exc() + sys.exit(1) + finally: + conn.close() + + print("\n=== Import complete ===") + print("Run: SELECT category, COUNT(*) FROM gear_items GROUP BY category;") + print(" to verify row counts.") + + +if __name__ == "__main__": + main() diff --git a/migrations_v2.sql b/migrations_v2.sql new file mode 100644 index 0000000..d1c9bf4 --- /dev/null +++ b/migrations_v2.sql @@ -0,0 +1,74 @@ +PRAGMA foreign_keys = ON; + +-- Unified gear item table for weapons, armor, helmets, backpacks, rigs, and mods/attachments +CREATE TABLE IF NOT EXISTS gear_items ( + id TEXT PRIMARY KEY, + api_id TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + short_name TEXT, + category TEXT NOT NULL, -- 'gun' | 'armor' | 'helmet' | 'backpack' | 'rig' | 'mod' + weight_kg REAL, + grid_image_url TEXT, + wiki_url TEXT, + -- Weapon-specific fields + caliber TEXT, + fire_rate INTEGER, + ergonomics INTEGER, + recoil_vertical INTEGER, + default_weight REAL, -- weight of gun with default preset mods attached + -- Armor / Helmet / Rig shared fields + armor_class INTEGER, -- 1-6, NULL if not applicable + durability REAL, + material TEXT, + zones TEXT, -- comma-separated protection zone names + head_zones TEXT, -- comma-separated head zone names (helmets only) + deafening TEXT, -- None | Low | Medium | High | Complete (helmets) + -- Backpack / Rig capacity + capacity INTEGER, -- total grid cell count + -- Mod/attachment classification + mod_type TEXT, -- 'suppressor' | 'scope' | 'flashlight' | 'foregrip' | 'stock' | etc. + imported_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_gear_category ON gear_items(category); +CREATE INDEX IF NOT EXISTS idx_gear_armor_class ON gear_items(armor_class); +CREATE INDEX IF NOT EXISTS idx_gear_capacity ON gear_items(capacity); +CREATE INDEX IF NOT EXISTS idx_gear_weight ON gear_items(weight_kg); +CREATE INDEX IF NOT EXISTS idx_gear_caliber ON gear_items(caliber); + +-- Weapon mod slots: records which named slots exist on each gun +CREATE TABLE IF NOT EXISTS gun_slots ( + gun_id TEXT NOT NULL REFERENCES gear_items(id) ON DELETE CASCADE, + slot_id TEXT NOT NULL, -- tarkov internal slot ID + slot_name TEXT NOT NULL, -- human-readable name (e.g. "Muzzle") + slot_nameid TEXT, -- normalized nameId (e.g. "mod_muzzle") + required INTEGER DEFAULT 0, -- 1 if the slot must be filled + PRIMARY KEY (gun_id, slot_id) +); + +CREATE INDEX IF NOT EXISTS idx_gun_slots_nameid ON gun_slots(slot_nameid); + +-- Which items are compatible with each gun slot (from API filters.allowedItems) +CREATE TABLE IF NOT EXISTS gun_slot_items ( + gun_id TEXT NOT NULL, + slot_id TEXT NOT NULL, + item_id TEXT NOT NULL REFERENCES gear_items(id) ON DELETE CASCADE, + PRIMARY KEY (gun_id, slot_id, item_id), + FOREIGN KEY (gun_id, slot_id) REFERENCES gun_slots(gun_id, slot_id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_gun_slot_items_item ON gun_slot_items(item_id); + +-- Saved loadout builds +CREATE TABLE IF NOT EXISTS saved_builds ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL DEFAULT 'My Build', + gun_id TEXT REFERENCES gear_items(id), + armor_id TEXT REFERENCES gear_items(id), + helmet_id TEXT REFERENCES gear_items(id), + rig_id TEXT REFERENCES gear_items(id), + backpack_id TEXT REFERENCES gear_items(id), + notes TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP +); diff --git a/tarkov.db b/tarkov.db index 1834c93..b211171 100644 Binary files a/tarkov.db and b/tarkov.db differ diff --git a/templates/collector.html b/templates/collector.html index 0d646f7..d7146f0 100644 --- a/templates/collector.html +++ b/templates/collector.html @@ -101,7 +101,13 @@
- +

Collector Checklist

{{ done }} / {{ total }} quests completed @@ -120,24 +126,53 @@ {% set ns.current_trader = quest.trader %} {% endif %} -

- - -
- - {{ quest.name }} - {% if quest.wiki_link %} - wiki - {% endif %} - - -
-
+
+ + {{ quest.name }} + {% if quest.wiki_link %} + wiki + {% endif %} + + +
{% endfor %} {% if ns.current_trader is not none %}
{% endif %} + diff --git a/templates/gun_detail.html b/templates/gun_detail.html new file mode 100644 index 0000000..f2e6e04 --- /dev/null +++ b/templates/gun_detail.html @@ -0,0 +1,319 @@ + + + + OnlyScavs – {{ gun.name }} + + + + +
+ + +
+ {% if gun.grid_image_url %} + {{ gun.name }} + {% endif %} +
+

{{ gun.name }}

+
+ {{ gun.caliber or '?' }} + {% if gun.wiki_url %} wiki ↗{% endif %} +
+
+
+ {{ "%.3f"|format(gun.weight_kg) if gun.weight_kg is not none else '?' }} + Base weight (kg) +
+ {% if gun.ergonomics %} +
+ {{ gun.ergonomics }} + Ergonomics +
+ {% endif %} + {% if gun.recoil_vertical %} +
+ {{ gun.recoil_vertical }} + Recoil (V) +
+ {% endif %} + {% if gun.fire_rate %} +
+ {{ gun.fire_rate }} + Fire rate +
+ {% endif %} +
+
+
+ +
+ Lightest possible build: + {{ "%.3f"|format(lightest_total) }} kg + base {{ "%.3f"|format(gun.weight_kg or 0) }} kg + lightest mod per slot +
+ + {% if key_slots %} +
+
Key slots
+ {% for slot in key_slots %} + {% set lightest = slot.mods[0] if slot.mods else none %} +
+
+ {{ slot.slot_name }} + key slot + {% if slot.required %}required{% endif %} + {{ slot.mods | length }} mods + + {% if lightest and lightest.weight_kg is not none %} + lightest {{ "%.3f"|format(lightest.weight_kg) }} kg + {% else %}—{% endif %} + + +
+
+ {% if slot.mods %} + {% for mod in slot.mods %} +
+ {% if mod.grid_image_url %} + + {% else %} +
+ {% endif %} +
+ {{ mod.mod_name }} + {% if mod.mod_short and mod.mod_short != mod.mod_name %} + {{ mod.mod_short }} + {% endif %} +
+ {% if mod.wiki_url %} + wiki + {% endif %} + + {% if mod.weight_kg is not none %}{{ "%.3f"|format(mod.weight_kg) }} kg{% else %}—{% endif %} + +
+ {% endfor %} + {% else %} +
No compatible mods found in database.
+ {% endif %} +
+
+ {% endfor %} +
+ {% endif %} + + {% if other_slots %} + +
+
+
All other slots
+ {% for slot in other_slots %} + {% set lightest = slot.mods[0] if slot.mods else none %} +
+
+ {{ slot.slot_name }} + {% if slot.required %}required{% endif %} + {{ slot.mods | length }} mods + + {% if lightest and lightest.weight_kg is not none %} + lightest {{ "%.3f"|format(lightest.weight_kg) }} kg + {% else %}—{% endif %} + + +
+
+ {% if slot.mods %} + {% for mod in slot.mods %} +
+ {% if mod.grid_image_url %} + + {% else %} +
+ {% endif %} +
+ {{ mod.mod_name }} + {% if mod.mod_short and mod.mod_short != mod.mod_name %} + {{ mod.mod_short }} + {% endif %} +
+ {% if mod.wiki_url %} + wiki + {% endif %} + + {% if mod.weight_kg is not none %}{{ "%.3f"|format(mod.weight_kg) }} kg{% else %}—{% endif %} + +
+ {% endfor %} + {% else %} +
No compatible mods found in database.
+ {% endif %} +
+
+ {% endfor %} +
+
+ {% endif %} + +
+ + + diff --git a/templates/index.html b/templates/index.html index d788b41..01d5e62 100644 --- a/templates/index.html +++ b/templates/index.html @@ -168,7 +168,13 @@
- +

OnlyScavs – Keys

diff --git a/templates/loadout.html b/templates/loadout.html new file mode 100644 index 0000000..9e5317f --- /dev/null +++ b/templates/loadout.html @@ -0,0 +1,748 @@ + + + + OnlyScavs – Loadout Planner + + + + +
+ +

Loadout Planner

+

+ Find the lightest gear for each slot. Filter by requirements. +

+ +
+ {% for t_id, t_label in [('guns','Guns'),('armor','Armor'),('helmets','Helmets'),('headwear','Headwear'),('backpacks','Backpacks'),('rigs','Rigs'),('builder','Build Builder')] %} + {{ t_label }} + {% endfor %} +
+ + {# =============================== GUNS TAB =============================== #} + {% if tab == "guns" %} + + + Must have slot: + {% for label, nameid in slot_filters %} + + {% endfor %} + + + + {% if requires %}clear{% endif %} + + + {% if requires %} +

+ "Lightest build" = gun base weight + lightest compatible mod per required slot. + Guns without all required slots are hidden. +

+ {% endif %} + + + + + + + + + + + + + + + {% for gun in guns %} + + + + + + + + + + + + + {% else %} + + {% endfor %} + +
NameCaliberErgoRecoilBase weight{% if requires %}Lightest build{% else %}Slots{% endif %}
+ {% if gun.grid_image_url %} + + {% endif %} + + {{ gun.short_name or gun.name }} + {% if gun.short_name and gun.short_name != gun.name %} + {{ gun.name }} + {% endif %} + {{ gun.caliber or '—' }}{{ gun.ergonomics or '—' }}{{ gun.recoil_vertical or '—' }} + {% if gun.weight_kg is not none %}{{ "%.3f"|format(gun.weight_kg) }} kg{% else %}—{% endif %} + + {% if requires %} + {% if gun.lightest_build_weight is not none %}{{ "%.3f"|format(gun.lightest_build_weight) }} kg{% else %}—{% endif %} + {% else %} + ▶ expand + {% endif %} +
No guns found matching those requirements.
+ + + + + {% endif %} + + {# =============================== ARMOR TAB =============================== #} + {% if tab == "armor" %} +
+ + + + + + +
+ + + + + + + + + {% for item in armor %} + + + + + + + + + + {% else %} + + {% endfor %} + +
NameClassDurabilityMaterialZonesWeight
{% if item.grid_image_url %}{% endif %} + {{ item.short_name or item.name }} + {% if item.short_name and item.short_name != item.name %}{{ item.name }}{% endif %} + {% if item.wiki_url %}wiki{% endif %} + + {% if item.armor_class %} + {{ item.armor_class }} + {% else %}—{% endif %} + {{ item.durability | int if item.durability else '—' }}{{ item.material or '—' }}{{ item.zones or '—' }}{% if item.weight_kg is not none %}{{ "%.3f"|format(item.weight_kg) }} kg{% else %}—{% endif %}
No armor found.
+ {% endif %} + + {# =============================== HELMETS TAB =============================== #} + {% if tab == "helmets" %} +
+ + + + + + +
+ + + + + + + + + {% for item in helmets %} + + + + + + + + + + {% else %} + + {% endfor %} + +
NameClassDurabilityHead zonesDeafeningWeight
{% if item.grid_image_url %}{% endif %} + {{ item.short_name or item.name }} + {% if item.short_name and item.short_name != item.name %}{{ item.name }}{% endif %} + {% if item.wiki_url %}wiki{% endif %} + + {% if item.armor_class %} + {{ item.armor_class }} + {% else %}{% endif %} + {{ item.durability | int if item.durability else '—' }}{{ item.head_zones or '—' }}{{ item.deafening or '—' }}{% if item.weight_kg is not none %}{{ "%.3f"|format(item.weight_kg) }} kg{% else %}—{% endif %}
No helmets found.
+ {% endif %} + + {# =============================== HEADWEAR TAB =============================== #} + {% if tab == "headwear" %} +
+ + + + + + +
+

Face masks, armored masks, and non-helmet head protection. Does not cover the top of the head.

+ + + + + + + + + {% for item in headwear %} + + + + + + + + + {% else %} + + {% endfor %} + +
NameClassDurabilityHead zonesWeight
{% if item.grid_image_url %}{% endif %} + {{ item.short_name or item.name }} + {% if item.short_name and item.short_name != item.name %}{{ item.name }}{% endif %} + {% if item.wiki_url %}wiki{% endif %} + + {% if item.armor_class %} + {{ item.armor_class }} + {% else %}{% endif %} + {{ item.durability | int if item.durability else '—' }}{{ item.head_zones or '—' }}{% if item.weight_kg is not none %}{{ "%.3f"|format(item.weight_kg) }} kg{% else %}—{% endif %}
No headwear found.
+ {% endif %} + + {# =============================== BACKPACKS TAB =============================== #} + {% if tab == "backpacks" %} +
+ + + + + + +
+ + + + + + + + {% for item in backpacks %} + + + + + + + {% else %} + + {% endfor %} + +
NameCapacity (slots)Weight
{% if item.grid_image_url %}{% endif %} + {{ item.short_name or item.name }} + {% if item.short_name and item.short_name != item.name %}{{ item.name }}{% endif %} + {% if item.wiki_url %}wiki{% endif %} + {{ item.capacity or '—' }}{% if item.weight_kg is not none %}{{ "%.3f"|format(item.weight_kg) }} kg{% else %}—{% endif %}
No backpacks found.
+ {% endif %} + + {# =============================== RIGS TAB =============================== #} + {% if tab == "rigs" %} +
+ + + + + + + + +
+ + + + + + + + {% for item in rigs %} + + + + + + + + + {% else %} + + {% endfor %} + +
NameClassCapacity (slots)ZonesWeight
{% if item.grid_image_url %}{% endif %} + {{ item.short_name or item.name }} + {% if item.short_name and item.short_name != item.name %}{{ item.name }}{% endif %} + {% if item.wiki_url %}wiki{% endif %} + + {% if item.armor_class %} + {{ item.armor_class }} + {% else %}{% endif %} + {{ item.capacity or '—' }}{{ item.zones or '—' }}{% if item.weight_kg is not none %}{{ "%.3f"|format(item.weight_kg) }} kg{% else %}—{% endif %}
No rigs found.
+ {% endif %} + + {# =============================== BUILD BUILDER TAB =============================== #} + {% if tab == "builder" %} + + +
+
Total loadout weight
+
0.000 kg
+
+ +
+ {% set slot_defs = [ + ('gun', 'Primary Weapon', builder_guns), + ('armor', 'Body Armor', builder_armor), + ('helmet', 'Helmet', builder_helmets), + ('rig', 'Chest Rig', builder_rigs), + ('backpack', 'Backpack', builder_backpacks), + ] %} + {% for slot_id, slot_label, items in slot_defs %} +
+

{{ slot_label }}

+ +
+
+ {% endfor %} +
+ +
+ + + +
+ {% endif %} + +
+ + diff --git a/templates/quests.html b/templates/quests.html new file mode 100644 index 0000000..9eaba34 --- /dev/null +++ b/templates/quests.html @@ -0,0 +1,244 @@ + + + + OnlyScavs – Quest Trees + + + + +
+ +

Quest Trees

+ +
+ All quests + ★ Collector only +
+ Required for Collector + Marked done + ← Trader Cross-trader dependency +
+
+ +{% 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 %} + + {% 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 #} + {% 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 %} + {% 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) }} + {% endfor %} +
+
+ {% endfor %} + +
+ + + +