Add Loadout Planner and Quest Trees templates
- Created loadout.html for a comprehensive loadout planner, allowing users to filter and view gear options across various categories including guns, armor, helmets, headwear, backpacks, and rigs. - Implemented a build builder feature to calculate total loadout weight and save builds. - Added quests.html to display quest trees with trader dependencies, filtering options, and quest completion tracking.
This commit is contained in:
358
app.py
358
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/<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))
|
||||
|
||||
# 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/<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/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__":
|
||||
|
||||
Reference in New Issue
Block a user