1072 lines
39 KiB
Python
1072 lines
39 KiB
Python
from flask import Flask, render_template, request, redirect, url_for, jsonify
|
|
import sqlite3
|
|
|
|
app = Flask(__name__, static_folder="assets", static_url_path="/assets")
|
|
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 landing():
|
|
return render_template("landing.html")
|
|
|
|
|
|
def _keys_context():
|
|
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 dict(keys=keys, maps=maps, key_maps=key_maps,
|
|
map_filter=map_filter, sort=sort, show=show)
|
|
|
|
|
|
@app.route("/keys")
|
|
def keys_page():
|
|
return render_template("keys.html", **_keys_context())
|
|
|
|
|
|
@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("keys_page", **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("keys_page", **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"
|
|
view = request.args.get("view", "flow") # "flow" or "list"
|
|
|
|
# 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 = set(collector_prereqs)
|
|
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,
|
|
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()
|
|
|
|
if not collector:
|
|
conn.close()
|
|
return "Run import_quests.py first to populate quest data.", 503
|
|
|
|
# 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 quest_id FROM deps
|
|
""", (collector["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 "")
|
|
|
|
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"])
|
|
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",
|
|
# carry_efficiency sorts are handled in Python after query; fall back to weight
|
|
"efficiency_desc": "weight_kg ASC NULLS LAST",
|
|
"efficiency_asc": "weight_kg ASC NULLS LAST",
|
|
}.get(sort, "weight_kg ASC NULLS LAST")
|
|
|
|
|
|
def _carry_efficiency(weight_kg, slot_count):
|
|
"""Return (slots_per_kg, kg_per_slot) or (None, None) if inputs are invalid."""
|
|
if not weight_kg or not slot_count:
|
|
return None, None
|
|
try:
|
|
w = float(weight_kg)
|
|
s = int(slot_count)
|
|
except (TypeError, ValueError):
|
|
return None, None
|
|
if w <= 0 or s <= 0:
|
|
return None, None
|
|
return round(s / w, 2), round(w / s, 3)
|
|
|
|
|
|
def _enrich_with_efficiency(rows):
|
|
"""Attach slots_per_kg and kg_per_slot to each sqlite3.Row (returns plain dicts)."""
|
|
enriched = []
|
|
for row in rows:
|
|
d = dict(row)
|
|
d["slots_per_kg"], d["kg_per_slot"] = _carry_efficiency(
|
|
d.get("weight_kg"), d.get("capacity")
|
|
)
|
|
enriched.append(d)
|
|
return enriched
|
|
|
|
|
|
def _sort_enriched(rows, sort):
|
|
"""Sort a list of enriched dicts by carry efficiency when requested."""
|
|
if sort == "efficiency_desc":
|
|
return sorted(rows, key=lambda r: (r["slots_per_kg"] is None, -(r["slots_per_kg"] or 0)))
|
|
if sort == "efficiency_asc":
|
|
return sorted(rows, key=lambda r: (r["slots_per_kg"] is None, r["slots_per_kg"] or 0))
|
|
return rows
|
|
|
|
|
|
@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 = armored_rigs = plates = []
|
|
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":
|
|
rows = conn.execute(f"""
|
|
SELECT * FROM gear_items
|
|
WHERE category = 'backpack'
|
|
AND (? = 0 OR capacity >= ?)
|
|
ORDER BY {sort_frag}
|
|
""", (min_capacity, min_capacity)).fetchall()
|
|
backpacks = _sort_enriched(_enrich_with_efficiency(rows), sort)
|
|
|
|
elif tab == "rigs":
|
|
rows = conn.execute(f"""
|
|
SELECT * FROM gear_items
|
|
WHERE category = 'rig'
|
|
AND armor_class IS NULL
|
|
AND (? = 0 OR capacity >= ?)
|
|
ORDER BY {sort_frag}
|
|
""", (min_capacity, min_capacity)).fetchall()
|
|
rigs = _sort_enriched(_enrich_with_efficiency(rows), sort)
|
|
|
|
elif tab == "armored_rigs":
|
|
rows = conn.execute(f"""
|
|
SELECT * FROM gear_items
|
|
WHERE category = 'rig'
|
|
AND armor_class IS NOT NULL
|
|
AND (? = 0 OR capacity >= ?)
|
|
AND (? = 0 OR armor_class >= ?)
|
|
ORDER BY {sort_frag}
|
|
""", (min_capacity, min_capacity, min_class, min_class)).fetchall()
|
|
armored_rigs = _sort_enriched(_enrich_with_efficiency(rows), sort)
|
|
|
|
elif tab == "plates":
|
|
plates = conn.execute(f"""
|
|
SELECT * FROM gear_items
|
|
WHERE category = 'plate'
|
|
AND (? = 0 OR armor_class >= ?)
|
|
ORDER BY {sort_frag}
|
|
""", (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()
|
|
|
|
# IDs of carriers that have at least one open plate slot (shell weight only)
|
|
open_slot_rows = conn.execute("SELECT DISTINCT carrier_id FROM armor_open_slots").fetchall()
|
|
carrier_ids_with_open_slots = {row["carrier_id"] for row in open_slot_rows}
|
|
|
|
conn.close()
|
|
return render_template(
|
|
"loadout.html",
|
|
tab=tab, sort=sort,
|
|
guns=guns, armor=armor, helmets=helmets, headwear=headwear,
|
|
backpacks=backpacks, rigs=rigs, armored_rigs=armored_rigs, plates=plates,
|
|
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,
|
|
carrier_ids_with_open_slots=carrier_ids_with_open_slots,
|
|
)
|
|
|
|
|
|
@app.route("/loadout/gun/<gun_id>")
|
|
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))
|
|
|
|
# Split into required vs optional slots
|
|
KEY_SLOTS = {"mod_muzzle", "mod_magazine"}
|
|
ordered_slots = [slots[s] for s in slot_order]
|
|
# Required slots (always needed) shown at top — key slots (magazine/muzzle) highlighted
|
|
key_slots = [s for s in ordered_slots if s["required"] and s["slot_nameid"] in KEY_SLOTS]
|
|
req_slots = [s for s in ordered_slots if s["required"] and s["slot_nameid"] not in KEY_SLOTS]
|
|
optional_slots = [s for s in ordered_slots if not s["required"]]
|
|
|
|
# Lightest total (base + lightest per REQUIRED slot only)
|
|
lightest_total = (gun["weight_kg"] or 0) + sum(
|
|
s["mods"][0]["weight_kg"]
|
|
for s in ordered_slots
|
|
if s["required"] and 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,
|
|
req_slots=req_slots,
|
|
optional_slots=optional_slots,
|
|
lightest_total=lightest_total,
|
|
)
|
|
|
|
|
|
@app.route("/loadout/gun/<gun_id>/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/carrier/<carrier_id>/slots.json")
|
|
def carrier_slots_json(carrier_id):
|
|
"""Returns open plate slots and allowed plates for a carrier (armor or rig)."""
|
|
conn = get_db()
|
|
rows = conn.execute("""
|
|
SELECT aos.slot_nameid, aos.zones,
|
|
p.id AS plate_id, p.name AS plate_name, p.short_name AS plate_short,
|
|
p.weight_kg, p.armor_class, p.durability, p.material
|
|
FROM armor_open_slots aos
|
|
LEFT JOIN armor_slot_plates asp ON asp.carrier_id = aos.carrier_id
|
|
AND asp.slot_nameid = aos.slot_nameid
|
|
LEFT JOIN gear_items p ON p.id = asp.plate_id
|
|
WHERE aos.carrier_id = ?
|
|
ORDER BY aos.slot_nameid, p.armor_class DESC, p.weight_kg ASC
|
|
""", (carrier_id,)).fetchall()
|
|
conn.close()
|
|
|
|
# Group by slot
|
|
slots = {}
|
|
slot_order = []
|
|
for row in rows:
|
|
sn = row["slot_nameid"]
|
|
if sn not in slots:
|
|
slots[sn] = {"slot_nameid": sn, "zones": row["zones"], "plates": []}
|
|
slot_order.append(sn)
|
|
if row["plate_id"]:
|
|
slots[sn]["plates"].append({
|
|
"id": row["plate_id"],
|
|
"name": row["plate_name"],
|
|
"short_name": row["plate_short"],
|
|
"weight_kg": row["weight_kg"],
|
|
"armor_class": row["armor_class"],
|
|
"durability": row["durability"],
|
|
"material": row["material"],
|
|
})
|
|
|
|
return jsonify([slots[s] for s in slot_order])
|
|
|
|
|
|
@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})
|
|
|
|
|
|
@app.route("/meds")
|
|
def meds():
|
|
import requests as _req
|
|
|
|
API_URL = "https://api.tarkov.dev/graphql"
|
|
query = """
|
|
{
|
|
items(types: [injectors], lang: en) {
|
|
id
|
|
name
|
|
shortName
|
|
iconLink
|
|
wikiLink
|
|
properties {
|
|
__typename
|
|
... on ItemPropertiesStim {
|
|
useTime
|
|
cures
|
|
stimEffects {
|
|
type
|
|
skill { name }
|
|
value
|
|
percent
|
|
duration
|
|
delay
|
|
chance
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
try:
|
|
resp = _req.post(API_URL, json={"query": query}, timeout=8)
|
|
raw_items = resp.json()["data"]["items"]
|
|
except Exception:
|
|
raw_items = []
|
|
|
|
# ── helper: pick first matching effect ──────────────────────────────
|
|
def pick(effects, type_str, positive_only=False, negative_only=False):
|
|
for e in effects:
|
|
if e["type"] == type_str:
|
|
if positive_only and e["value"] <= 0:
|
|
continue
|
|
if negative_only and e["value"] >= 0:
|
|
continue
|
|
return e
|
|
return None
|
|
|
|
def pick_skill(effects, skill_name):
|
|
for e in effects:
|
|
if e["type"] == "Skill" and e.get("skill") and e["skill"]["name"] == skill_name:
|
|
return e
|
|
return None
|
|
|
|
# ── collect all skill names across all injectors ──────────────────
|
|
all_skills = []
|
|
for item in raw_items:
|
|
p = item.get("properties") or {}
|
|
for e in p.get("stimEffects", []):
|
|
if e["type"] == "Skill" and e.get("skill"):
|
|
sn = e["skill"]["name"]
|
|
if sn not in all_skills:
|
|
all_skills.append(sn)
|
|
skill_rows = sorted(all_skills)
|
|
|
|
# ── build injector data rows ──────────────────────────────────────
|
|
injectors = []
|
|
for item in raw_items:
|
|
p = item.get("properties") or {}
|
|
effs = p.get("stimEffects", [])
|
|
|
|
def _val(eff): return round(eff["value"], 2) if eff else None
|
|
def _dur(eff): return eff["duration"] if eff else 0
|
|
def _delay(eff): return eff["delay"] if eff else 0
|
|
|
|
hp_e = pick(effs, "Health regeneration", positive_only=True)
|
|
stam_e = pick(effs, "Stamina recovery", positive_only=True)
|
|
stam_neg_e = pick(effs, "Stamina recovery", negative_only=True)
|
|
stam_rec_e = stam_e or stam_neg_e
|
|
maxstam_e = pick(effs, "Max stamina")
|
|
weight_e = pick(effs, "Weight limit")
|
|
energy_e = pick(effs, "Energy recovery")
|
|
hydra_e = pick(effs, "Hydration recovery")
|
|
bleed_e = pick(effs, "Stops and prevents bleedings")
|
|
anti_e = pick(effs, "Antidote")
|
|
tremor_e = pick(effs, "Hands tremor")
|
|
tunnel_e = pick(effs, "Tunnel effect")
|
|
pain_e = pick(effs, "Pain")
|
|
temp_e = pick(effs, "Body temperature")
|
|
|
|
# skills dict
|
|
skills = {}
|
|
for sn in skill_rows:
|
|
se = pick_skill(effs, sn)
|
|
if se:
|
|
skills[sn] = {"value": round(se["value"], 1), "duration": se["duration"]}
|
|
|
|
# tags for column filtering
|
|
tags = []
|
|
if hp_e or bleed_e or anti_e:
|
|
tags.append("heal")
|
|
if maxstam_e or stam_rec_e:
|
|
tags.append("stam")
|
|
if skills:
|
|
tags.append("skill")
|
|
if temp_e or weight_e:
|
|
tags.append("special")
|
|
if not tags:
|
|
tags.append("special")
|
|
|
|
injectors.append({
|
|
"name": item["name"],
|
|
"short": item["shortName"],
|
|
"icon": item.get("iconLink"),
|
|
"wiki": item.get("wikiLink"),
|
|
"tags": ",".join(tags),
|
|
# healing
|
|
"hp_regen": _val(hp_e),
|
|
"hp_regen_dur": _dur(hp_e),
|
|
"stops_bleed": bool(bleed_e),
|
|
"antidote": bool(anti_e),
|
|
# stamina
|
|
"max_stam": _val(maxstam_e),
|
|
"max_stam_dur": _dur(maxstam_e),
|
|
"stam_rec": _val(stam_rec_e),
|
|
"stam_rec_dur": _dur(stam_rec_e),
|
|
# weight
|
|
"weight": _val(weight_e),
|
|
"weight_dur": _dur(weight_e),
|
|
# special
|
|
"body_temp": _val(temp_e),
|
|
"body_temp_dur": _dur(temp_e),
|
|
"energy": round(energy_e["value"], 2) if energy_e else None,
|
|
"energy_dur": _dur(energy_e),
|
|
"hydration": round(hydra_e["value"], 2) if hydra_e else None,
|
|
"hydration_dur": _dur(hydra_e),
|
|
# skills
|
|
"skills": skills,
|
|
# side effects
|
|
"tremor": bool(tremor_e),
|
|
"tremor_delay": _delay(tremor_e),
|
|
"tremor_dur": _dur(tremor_e),
|
|
"tunnel": bool(tunnel_e),
|
|
"tunnel_delay": _delay(tunnel_e),
|
|
"tunnel_dur": _dur(tunnel_e),
|
|
"pain": bool(pain_e),
|
|
"pain_delay": _delay(pain_e),
|
|
"pain_dur": _dur(pain_e),
|
|
})
|
|
|
|
# ── situation guide ───────────────────────────────────────────────
|
|
situations = {
|
|
"bleed": [
|
|
{"short": "Zagustin", "desc": "Stops bleeding, +Vitality 180s", "warn": "tremors delayed"},
|
|
{"short": "AHF1-M", "desc": "Stops bleeding, +Health 60s", "warn": "-hydration"},
|
|
{"short": "Perfotoran", "desc": "Stops bleed + antidote + regen", "warn": "-energy after"},
|
|
{"short": "xTG-12", "desc": "Antidote only (no bleed stop)", "warn": "-Health skill"},
|
|
],
|
|
"regen": [
|
|
{"short": "eTG-c", "desc": "+6.5 HP/s for 60s (fast burst)", "warn": "-energy after"},
|
|
{"short": "Adrenaline","desc": "+4 HP/s for 15s, stam boost", "warn": "-hydration after"},
|
|
{"short": "PNB", "desc": "+3 HP/s for 40s, +Strength", "warn": "tremors + skill debuff"},
|
|
{"short": "Propital", "desc": "+1 HP/s for 300s, skill buffs", "warn": "tremors at 270s"},
|
|
{"short": "Perfotoran","desc": "+1.5 HP/s for 60s + antidote", "warn": "-energy after"},
|
|
],
|
|
"stam": [
|
|
{"short": "Trimadol", "desc": "+3 stam rec, +10 max stam, 180s", "warn": "-energy/-hydration"},
|
|
{"short": "SJ6", "desc": "+2 stam rec, +30 max stam, 240s", "warn": "tremors + tunnel after"},
|
|
{"short": "Meldonin", "desc": "+0.5 stam rec, +Endurance 900s", "warn": "-hydration/-energy (minor)"},
|
|
{"short": "L1", "desc": "+30 max stam, +Strength, 120s", "warn": "-hydration/-energy"},
|
|
{"short": "SJ1", "desc": "+Endurance/Strength 180s", "warn": "-energy/-hydration after"},
|
|
{"short": "Adrenaline","desc": "Short burst +Endurance/Strength", "warn": "-Stress Resist"},
|
|
],
|
|
"skill": [
|
|
{"short": "Obdolbos 2", "desc": "All skills +20, weight +45%, 1800s", "warn": "-stam, -HP regen"},
|
|
{"short": "3-(b-TG)", "desc": "+Attention/Perception/Strength 240s", "warn": "tremors after"},
|
|
{"short": "SJ12", "desc": "+Perception 600s, body cool", "warn": "overheats at end"},
|
|
{"short": "2A2-(b-TG)", "desc": "+Attention/Perception, weight +15%, 900s", "warn": "-hydration"},
|
|
{"short": "Trimadol", "desc": "Broad skill buff + stam, 180s", "warn": "-energy/-hydration"},
|
|
],
|
|
"special": [
|
|
{"short": "SJ9", "desc": "Cools body -7°, 300s — hot map survival", "warn": "HP drain + tremors"},
|
|
{"short": "SJ12", "desc": "Cools body -4°, +Perception, 600s", "warn": "rebound heat after"},
|
|
{"short": "M.U.L.E.", "desc": "Weight limit +50% for 900s", "warn": "-HP regen"},
|
|
{"short": "Obdolbos","desc": "25% chance: all buffs + all debuffs", "warn": "may kill you"},
|
|
],
|
|
"risky": [
|
|
{"short": "Obdolbos", "desc": "25% chance everything fires at once", "warn": "may cause -600 HP"},
|
|
{"short": "PNB", "desc": "Fast HP/Strength burst then hard crash", "warn": "-Health/-Vitality 180s"},
|
|
{"short": "SJ9", "desc": "-HP regen whole duration, tremors", "warn": "don't use while injured"},
|
|
{"short": "Propital", "desc": "Tremors + tunnel vision at 270s delay", "warn": "plan ahead"},
|
|
],
|
|
}
|
|
|
|
return render_template("meds.html",
|
|
injectors=injectors,
|
|
skill_rows=skill_rows,
|
|
situations=situations)
|
|
|
|
|
|
@app.route("/barters")
|
|
def barters():
|
|
import requests as _req
|
|
|
|
API_URL = "https://api.tarkov.dev/graphql"
|
|
query = """
|
|
{
|
|
barters(lang: en) {
|
|
id
|
|
trader { name }
|
|
level
|
|
taskUnlock { name }
|
|
requiredItems {
|
|
item { id name shortName iconLink wikiLink }
|
|
count
|
|
}
|
|
rewardItems {
|
|
item { id name shortName iconLink wikiLink }
|
|
count
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
try:
|
|
resp = _req.post(API_URL, json={"query": query}, timeout=15)
|
|
data = resp.json()
|
|
raw_barters = data.get("data", {}).get("barters", [])
|
|
except Exception:
|
|
raw_barters = []
|
|
|
|
barter_list = []
|
|
for b in raw_barters:
|
|
reward_items = b.get("rewardItems", [])
|
|
required_items = b.get("requiredItems", [])
|
|
if not reward_items or not required_items:
|
|
continue
|
|
|
|
# Use first reward item as the "output" item
|
|
reward = reward_items[0]
|
|
reward_item = reward.get("item") or {}
|
|
reward_count = reward.get("count", 1)
|
|
|
|
required = []
|
|
for ri in required_items:
|
|
item = ri.get("item") or {}
|
|
required.append({
|
|
"id": item.get("id", ""),
|
|
"name": item.get("name", "Unknown"),
|
|
"short": item.get("shortName", ""),
|
|
"icon": item.get("iconLink"),
|
|
"count": ri.get("count", 1),
|
|
})
|
|
|
|
task_unlock = b.get("taskUnlock")
|
|
barter_list.append({
|
|
"id": b.get("id", ""),
|
|
"trader": (b.get("trader") or {}).get("name", "Unknown"),
|
|
"level": b.get("level", 1),
|
|
"task_unlock": task_unlock.get("name") if task_unlock else None,
|
|
"reward_name": reward_item.get("name", "Unknown"),
|
|
"reward_short": reward_item.get("shortName", ""),
|
|
"reward_icon": reward_item.get("iconLink"),
|
|
"reward_wiki": reward_item.get("wikiLink"),
|
|
"reward_count": reward_count,
|
|
"required": required,
|
|
})
|
|
|
|
barter_list.sort(key=lambda b: (b["trader"], b["level"], b["reward_name"]))
|
|
return render_template("barters.html", barters=barter_list)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
app.run(host="0.0.0.0", port=5000, debug=True)
|