Add: armor plates support with new database tables and loadout functionality
fix: only required mods are applied to guns for weight management.
This commit is contained in:
71
app.py
71
app.py
@@ -357,7 +357,7 @@ def loadout():
|
|||||||
tab = request.args.get("tab", "guns")
|
tab = request.args.get("tab", "guns")
|
||||||
sort = request.args.get("sort", "weight_asc")
|
sort = request.args.get("sort", "weight_asc")
|
||||||
|
|
||||||
guns = armor = helmets = headwear = backpacks = rigs = []
|
guns = armor = helmets = headwear = backpacks = rigs = plates = []
|
||||||
builder_guns = builder_armor = builder_helmets = builder_rigs = builder_backpacks = []
|
builder_guns = builder_armor = builder_helmets = builder_rigs = builder_backpacks = []
|
||||||
requires = request.args.getlist("requires") # list of slot_nameids that must exist
|
requires = request.args.getlist("requires") # list of slot_nameids that must exist
|
||||||
min_class = request.args.get("min_class", 0, type=int)
|
min_class = request.args.get("min_class", 0, type=int)
|
||||||
@@ -443,6 +443,14 @@ def loadout():
|
|||||||
ORDER BY {sort_frag}
|
ORDER BY {sort_frag}
|
||||||
""", (min_capacity, min_capacity, min_class, min_class)).fetchall()
|
""", (min_capacity, min_capacity, min_class, min_class)).fetchall()
|
||||||
|
|
||||||
|
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":
|
elif tab == "builder":
|
||||||
builder_guns = conn.execute("SELECT id, name, weight_kg FROM gear_items WHERE category='gun' ORDER BY name").fetchall()
|
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_armor = conn.execute("SELECT id, name, weight_kg FROM gear_items WHERE category='armor' ORDER BY name").fetchall()
|
||||||
@@ -450,12 +458,16 @@ def loadout():
|
|||||||
builder_rigs = conn.execute("SELECT id, name, weight_kg FROM gear_items WHERE category='rig' 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()
|
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()
|
conn.close()
|
||||||
return render_template(
|
return render_template(
|
||||||
"loadout.html",
|
"loadout.html",
|
||||||
tab=tab, sort=sort,
|
tab=tab, sort=sort,
|
||||||
guns=guns, armor=armor, helmets=helmets, headwear=headwear,
|
guns=guns, armor=armor, helmets=helmets, headwear=headwear,
|
||||||
backpacks=backpacks, rigs=rigs,
|
backpacks=backpacks, rigs=rigs, plates=plates,
|
||||||
slot_filters=LOADOUT_SLOT_FILTERS,
|
slot_filters=LOADOUT_SLOT_FILTERS,
|
||||||
requires=requires,
|
requires=requires,
|
||||||
min_class=min_class, min_capacity=min_capacity,
|
min_class=min_class, min_capacity=min_capacity,
|
||||||
@@ -464,6 +476,7 @@ def loadout():
|
|||||||
builder_helmets=builder_helmets,
|
builder_helmets=builder_helmets,
|
||||||
builder_rigs=builder_rigs,
|
builder_rigs=builder_rigs,
|
||||||
builder_backpacks=builder_backpacks,
|
builder_backpacks=builder_backpacks,
|
||||||
|
carrier_ids_with_open_slots=carrier_ids_with_open_slots,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -506,17 +519,19 @@ def gun_detail(gun_id):
|
|||||||
if row["mod_id"]:
|
if row["mod_id"]:
|
||||||
slots[sid]["mods"].append(dict(row))
|
slots[sid]["mods"].append(dict(row))
|
||||||
|
|
||||||
# Key slots to show at top (highlighted)
|
# Split into required vs optional slots
|
||||||
KEY_SLOTS = {"mod_muzzle", "mod_magazine"}
|
KEY_SLOTS = {"mod_muzzle", "mod_magazine"}
|
||||||
ordered_slots = [slots[s] for s in slot_order]
|
ordered_slots = [slots[s] for s in slot_order]
|
||||||
key_slots = [s for s in ordered_slots if s["slot_nameid"] in KEY_SLOTS]
|
# Required slots (always needed) shown at top — key slots (magazine/muzzle) highlighted
|
||||||
other_slots = [s for s in ordered_slots if s["slot_nameid"] not in KEY_SLOTS]
|
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 slot)
|
# Lightest total (base + lightest per REQUIRED slot only)
|
||||||
lightest_total = (gun["weight_kg"] or 0) + sum(
|
lightest_total = (gun["weight_kg"] or 0) + sum(
|
||||||
s["mods"][0]["weight_kg"]
|
s["mods"][0]["weight_kg"]
|
||||||
for s in ordered_slots
|
for s in ordered_slots
|
||||||
if s["mods"] and s["mods"][0]["weight_kg"] is not None
|
if s["required"] and s["mods"] and s["mods"][0]["weight_kg"] is not None
|
||||||
)
|
)
|
||||||
|
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -524,7 +539,8 @@ def gun_detail(gun_id):
|
|||||||
"gun_detail.html",
|
"gun_detail.html",
|
||||||
gun=gun,
|
gun=gun,
|
||||||
key_slots=key_slots,
|
key_slots=key_slots,
|
||||||
other_slots=other_slots,
|
req_slots=req_slots,
|
||||||
|
optional_slots=optional_slots,
|
||||||
lightest_total=lightest_total,
|
lightest_total=lightest_total,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -571,6 +587,45 @@ def gun_slots_json(gun_id):
|
|||||||
return jsonify(result)
|
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"])
|
@app.route("/loadout/save-build", methods=["POST"])
|
||||||
def save_build():
|
def save_build():
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
|
|||||||
125
import_gear.py
125
import_gear.py
@@ -64,6 +64,14 @@ GRAPHQL_QUERY_GEAR = """
|
|||||||
durability
|
durability
|
||||||
material { name }
|
material { name }
|
||||||
zones
|
zones
|
||||||
|
armorSlots {
|
||||||
|
__typename
|
||||||
|
... on ItemArmorSlotOpen {
|
||||||
|
nameId
|
||||||
|
zones
|
||||||
|
allowedPlates { id }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -112,6 +120,26 @@ GRAPHQL_QUERY_GEAR = """
|
|||||||
class
|
class
|
||||||
durability
|
durability
|
||||||
zones
|
zones
|
||||||
|
armorSlots {
|
||||||
|
__typename
|
||||||
|
... on ItemArmorSlotOpen {
|
||||||
|
nameId
|
||||||
|
zones
|
||||||
|
allowedPlates { id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
plates: items(types: [armorPlate]) {
|
||||||
|
id name shortName weight gridImageLink wikiLink
|
||||||
|
properties {
|
||||||
|
... on ItemPropertiesArmorAttachment {
|
||||||
|
class
|
||||||
|
durability
|
||||||
|
material { name }
|
||||||
|
zones
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -211,6 +239,7 @@ def import_weapons(conn, weapons):
|
|||||||
def import_armor(conn, items):
|
def import_armor(conn, items):
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
counts = {"inserted": 0, "updated": 0, "skipped": 0}
|
counts = {"inserted": 0, "updated": 0, "skipped": 0}
|
||||||
|
slot_data = {} # carrier_id -> armorSlots list
|
||||||
for item in items:
|
for item in items:
|
||||||
item_id = item.get("id")
|
item_id = item.get("id")
|
||||||
name = item.get("name")
|
name = item.get("name")
|
||||||
@@ -229,8 +258,11 @@ def import_armor(conn, items):
|
|||||||
zones=zones,
|
zones=zones,
|
||||||
)
|
)
|
||||||
counts[result] += 1
|
counts[result] += 1
|
||||||
|
armor_slots = props.get("armorSlots") or []
|
||||||
|
if armor_slots:
|
||||||
|
slot_data[item_id] = armor_slots
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return counts
|
return counts, slot_data
|
||||||
|
|
||||||
|
|
||||||
def import_helmets(conn, items):
|
def import_helmets(conn, items):
|
||||||
@@ -291,6 +323,7 @@ def import_backpacks(conn, items):
|
|||||||
def import_rigs(conn, items):
|
def import_rigs(conn, items):
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
counts = {"inserted": 0, "updated": 0, "skipped": 0}
|
counts = {"inserted": 0, "updated": 0, "skipped": 0}
|
||||||
|
slot_data = {} # carrier_id -> armorSlots list
|
||||||
for item in items:
|
for item in items:
|
||||||
item_id = item.get("id")
|
item_id = item.get("id")
|
||||||
name = item.get("name")
|
name = item.get("name")
|
||||||
@@ -308,10 +341,75 @@ def import_rigs(conn, items):
|
|||||||
zones=zones,
|
zones=zones,
|
||||||
)
|
)
|
||||||
counts[result] += 1
|
counts[result] += 1
|
||||||
|
armor_slots = props.get("armorSlots") or []
|
||||||
|
if armor_slots:
|
||||||
|
slot_data[item_id] = armor_slots
|
||||||
|
conn.commit()
|
||||||
|
return counts, slot_data
|
||||||
|
|
||||||
|
|
||||||
|
def import_plates(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"), "plate", 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()
|
conn.commit()
|
||||||
return counts
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
def import_armor_open_slots(conn, carrier_id, armor_slots):
|
||||||
|
"""
|
||||||
|
Insert open plate slots and their compatible plates for a carrier item.
|
||||||
|
armor_slots: list of armorSlots from the API (only ItemArmorSlotOpen are relevant).
|
||||||
|
"""
|
||||||
|
cursor = conn.cursor()
|
||||||
|
slot_count = 0
|
||||||
|
plate_count = 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 slot in armor_slots:
|
||||||
|
if slot.get("__typename") != "ItemArmorSlotOpen":
|
||||||
|
continue
|
||||||
|
slot_nameid = slot.get("nameId")
|
||||||
|
if not slot_nameid:
|
||||||
|
continue
|
||||||
|
zones = ",".join(slot.get("zones") or []) or None
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT OR REPLACE INTO armor_open_slots (carrier_id, slot_nameid, zones)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
""", (carrier_id, slot_nameid, zones))
|
||||||
|
slot_count += 1
|
||||||
|
|
||||||
|
for plate in (slot.get("allowedPlates") or []):
|
||||||
|
plate_id = plate.get("id")
|
||||||
|
if not plate_id or plate_id not in known_ids:
|
||||||
|
continue
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT OR IGNORE INTO armor_slot_plates (carrier_id, slot_nameid, plate_id)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
""", (carrier_id, slot_nameid, plate_id))
|
||||||
|
plate_count += 1
|
||||||
|
|
||||||
|
return slot_count, plate_count
|
||||||
|
|
||||||
|
|
||||||
def import_suppressors(conn, items):
|
def import_suppressors(conn, items):
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
counts = {"inserted": 0, "updated": 0, "skipped": 0}
|
counts = {"inserted": 0, "updated": 0, "skipped": 0}
|
||||||
@@ -438,6 +536,7 @@ def main():
|
|||||||
rigs = gear_data.get("rigs", [])
|
rigs = gear_data.get("rigs", [])
|
||||||
suppressors = gear_data.get("suppressors", [])
|
suppressors = gear_data.get("suppressors", [])
|
||||||
mods = gear_data.get("mods", [])
|
mods = gear_data.get("mods", [])
|
||||||
|
plates = gear_data.get("plates", [])
|
||||||
|
|
||||||
# Wearables: only keep those whose properties resolved to ItemPropertiesHelmet
|
# Wearables: only keep those whose properties resolved to ItemPropertiesHelmet
|
||||||
# (i.e. they have 'class' or 'headZones' set — not decorative items)
|
# (i.e. they have 'class' or 'headZones' set — not decorative items)
|
||||||
@@ -451,7 +550,8 @@ def main():
|
|||||||
and (w["properties"].get("class") or w["properties"].get("headZones"))
|
and (w["properties"].get("class") or w["properties"].get("headZones"))
|
||||||
]
|
]
|
||||||
print(f" armor={len(armor)}, helmets={len(helmets)}, wearable_helmets={len(wearable_helmets)}, "
|
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)}")
|
f"backpacks={len(backpacks)}, rigs={len(rigs)}, suppressors={len(suppressors)}, "
|
||||||
|
f"mods={len(mods)}, plates={len(plates)})")
|
||||||
|
|
||||||
# Phase 2: insert into DB
|
# Phase 2: insert into DB
|
||||||
conn = sqlite3.connect(DB_PATH)
|
conn = sqlite3.connect(DB_PATH)
|
||||||
@@ -462,8 +562,12 @@ def main():
|
|||||||
wcounts, slot_data = import_weapons(conn, weapons)
|
wcounts, slot_data = import_weapons(conn, weapons)
|
||||||
print(f" guns: +{wcounts['inserted']} inserted, ~{wcounts['updated']} updated, -{wcounts['skipped']} skipped")
|
print(f" guns: +{wcounts['inserted']} inserted, ~{wcounts['updated']} updated, -{wcounts['skipped']} skipped")
|
||||||
|
|
||||||
|
print("Importing plates (armorPlate items)...")
|
||||||
|
c = import_plates(conn, plates)
|
||||||
|
print(f" plates: +{c['inserted']} inserted, ~{c['updated']} updated, -{c['skipped']} skipped")
|
||||||
|
|
||||||
print("Importing armor...")
|
print("Importing armor...")
|
||||||
c = import_armor(conn, armor)
|
c, armor_slot_data = import_armor(conn, armor)
|
||||||
print(f" armor: +{c['inserted']} inserted, ~{c['updated']} updated, -{c['skipped']} skipped")
|
print(f" armor: +{c['inserted']} inserted, ~{c['updated']} updated, -{c['skipped']} skipped")
|
||||||
|
|
||||||
print("Importing helmets...")
|
print("Importing helmets...")
|
||||||
@@ -479,7 +583,7 @@ def main():
|
|||||||
print(f" backpacks: +{c['inserted']} inserted, ~{c['updated']} updated, -{c['skipped']} skipped")
|
print(f" backpacks: +{c['inserted']} inserted, ~{c['updated']} updated, -{c['skipped']} skipped")
|
||||||
|
|
||||||
print("Importing rigs...")
|
print("Importing rigs...")
|
||||||
c = import_rigs(conn, rigs)
|
c, rig_slot_data = import_rigs(conn, rigs)
|
||||||
print(f" rigs: +{c['inserted']} inserted, ~{c['updated']} updated, -{c['skipped']} skipped")
|
print(f" rigs: +{c['inserted']} inserted, ~{c['updated']} updated, -{c['skipped']} skipped")
|
||||||
|
|
||||||
print("Importing suppressors...")
|
print("Importing suppressors...")
|
||||||
@@ -496,6 +600,19 @@ def main():
|
|||||||
print(f" gun_slots: {slots_ins} rows")
|
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)")
|
print(f" gun_slot_items: {slot_items_ins} rows inserted, {slot_items_skip} skipped (item not in DB)")
|
||||||
|
|
||||||
|
# Phase 3b: armor open slots (needs plates in DB first)
|
||||||
|
print("Importing armor open plate slots...")
|
||||||
|
total_open_slots = 0
|
||||||
|
total_plate_links = 0
|
||||||
|
all_carrier_slots = {**armor_slot_data, **rig_slot_data}
|
||||||
|
for carrier_id, armor_slots in all_carrier_slots.items():
|
||||||
|
sc, pc = import_armor_open_slots(conn, carrier_id, armor_slots)
|
||||||
|
total_open_slots += sc
|
||||||
|
total_plate_links += pc
|
||||||
|
conn.commit()
|
||||||
|
print(f" armor_open_slots: {total_open_slots} rows")
|
||||||
|
print(f" armor_slot_plates: {total_plate_links} rows")
|
||||||
|
|
||||||
# Phase 4: classify mod_type for mods based on which slots they appear in
|
# Phase 4: classify mod_type for mods based on which slots they appear in
|
||||||
print("Classifying mod types from slot data...")
|
print("Classifying mod types from slot data...")
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|||||||
21
migration_add_plates.sql
Normal file
21
migration_add_plates.sql
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
-- Migration: add armor plates support
|
||||||
|
-- Run: sqlite3 tarkov.db < migration_add_plates.sql
|
||||||
|
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS armor_open_slots (
|
||||||
|
carrier_id TEXT NOT NULL REFERENCES gear_items(id) ON DELETE CASCADE,
|
||||||
|
slot_nameid TEXT NOT NULL,
|
||||||
|
zones TEXT,
|
||||||
|
PRIMARY KEY (carrier_id, slot_nameid)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS armor_slot_plates (
|
||||||
|
carrier_id TEXT NOT NULL,
|
||||||
|
slot_nameid TEXT NOT NULL,
|
||||||
|
plate_id TEXT NOT NULL REFERENCES gear_items(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (carrier_id, slot_nameid, plate_id),
|
||||||
|
FOREIGN KEY (carrier_id, slot_nameid) REFERENCES armor_open_slots(carrier_id, slot_nameid) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_armor_slot_plates_plate ON armor_slot_plates(plate_id);
|
||||||
@@ -60,6 +60,26 @@ CREATE TABLE IF NOT EXISTS gun_slot_items (
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_gun_slot_items_item ON gun_slot_items(item_id);
|
CREATE INDEX IF NOT EXISTS idx_gun_slot_items_item ON gun_slot_items(item_id);
|
||||||
|
|
||||||
|
-- Open plate slots on armor/rig carriers (ItemArmorSlotOpen from tarkov API)
|
||||||
|
-- Weight of a carrier does NOT include plates in these slots
|
||||||
|
CREATE TABLE IF NOT EXISTS armor_open_slots (
|
||||||
|
carrier_id TEXT NOT NULL REFERENCES gear_items(id) ON DELETE CASCADE,
|
||||||
|
slot_nameid TEXT NOT NULL, -- e.g. "front_plate", "back_plate", "left_side_plate"
|
||||||
|
zones TEXT, -- comma-separated zone names e.g. "FR. PLATE,BCK. PLATE"
|
||||||
|
PRIMARY KEY (carrier_id, slot_nameid)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Which plates are compatible with each open slot on a carrier
|
||||||
|
CREATE TABLE IF NOT EXISTS armor_slot_plates (
|
||||||
|
carrier_id TEXT NOT NULL,
|
||||||
|
slot_nameid TEXT NOT NULL,
|
||||||
|
plate_id TEXT NOT NULL REFERENCES gear_items(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (carrier_id, slot_nameid, plate_id),
|
||||||
|
FOREIGN KEY (carrier_id, slot_nameid) REFERENCES armor_open_slots(carrier_id, slot_nameid) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_armor_slot_plates_plate ON armor_slot_plates(plate_id);
|
||||||
|
|
||||||
-- Saved loadout builds
|
-- Saved loadout builds
|
||||||
CREATE TABLE IF NOT EXISTS saved_builds (
|
CREATE TABLE IF NOT EXISTS saved_builds (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|||||||
@@ -197,19 +197,16 @@
|
|||||||
<div class="total-bar">
|
<div class="total-bar">
|
||||||
<span class="lbl">Lightest possible build:</span>
|
<span class="lbl">Lightest possible build:</span>
|
||||||
<span class="big">{{ "%.3f"|format(lightest_total) }} kg</span>
|
<span class="big">{{ "%.3f"|format(lightest_total) }} kg</span>
|
||||||
<span class="breakdown">base {{ "%.3f"|format(gun.weight_kg or 0) }} kg + lightest mod per slot</span>
|
<span class="breakdown">base {{ "%.3f"|format(gun.weight_kg or 0) }} kg + lightest mod per required slot</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if key_slots %}
|
{# ---- Macro to render a slot card ---- #}
|
||||||
<div class="slot-section">
|
{% macro slot_card(slot, extra_class='', open=False) %}
|
||||||
<div class="section-hdr">Key slots</div>
|
|
||||||
{% for slot in key_slots %}
|
|
||||||
{% set lightest = slot.mods[0] if slot.mods else none %}
|
{% set lightest = slot.mods[0] if slot.mods else none %}
|
||||||
<div class="slot-card key-slot {% if loop.first %}open{% endif %}" id="slot-{{ slot.slot_id }}">
|
<div class="slot-card {{ extra_class }} {% if open %}open{% endif %}" id="slot-{{ slot.slot_id }}">
|
||||||
<div class="slot-header" onclick="toggleSlot('slot-{{ slot.slot_id }}')">
|
<div class="slot-header" onclick="toggleSlot('slot-{{ slot.slot_id }}')">
|
||||||
<span class="slot-name">{{ slot.slot_name }}</span>
|
<span class="slot-name">{{ slot.slot_name }}</span>
|
||||||
<span class="key-badge">key slot</span>
|
{% if extra_class == 'key-slot' %}<span class="key-badge">key slot</span>{% endif %}
|
||||||
{% if slot.required %}<span class="required-badge">required</span>{% endif %}
|
|
||||||
<span class="slot-count">{{ slot.mods | length }} mods</span>
|
<span class="slot-count">{{ slot.mods | length }} mods</span>
|
||||||
<span class="slot-lightest">
|
<span class="slot-lightest">
|
||||||
{% if lightest and lightest.weight_kg is not none %}
|
{% if lightest and lightest.weight_kg is not none %}
|
||||||
@@ -246,57 +243,29 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{# ---- Required slots (key slots highlighted, open by default) ---- #}
|
||||||
|
{% if key_slots or req_slots %}
|
||||||
|
<div class="slot-section">
|
||||||
|
<div class="section-hdr">Required slots <span style="font-weight:normal;color:#666">(counted in lightest build weight)</span></div>
|
||||||
|
{% for slot in key_slots %}
|
||||||
|
{{ slot_card(slot, 'key-slot', open=True) }}
|
||||||
|
{% endfor %}
|
||||||
|
{% for slot in req_slots %}
|
||||||
|
{{ slot_card(slot, '', open=False) }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if other_slots %}
|
{# ---- Optional slots (collapsed behind toggle) ---- #}
|
||||||
<button class="toggle-other" onclick="toggleOther(this)">Show {{ other_slots | length }} other slots ▼</button>
|
{% if optional_slots %}
|
||||||
|
<button class="toggle-other" onclick="toggleOther(this)">Show {{ optional_slots | length }} optional slots ▼</button>
|
||||||
<div id="other-slots">
|
<div id="other-slots">
|
||||||
<div class="slot-section">
|
<div class="slot-section">
|
||||||
<div class="section-hdr">All other slots</div>
|
<div class="section-hdr">Optional slots <span style="font-weight:normal;color:#666">(not counted in lightest build weight)</span></div>
|
||||||
{% for slot in other_slots %}
|
{% for slot in optional_slots %}
|
||||||
{% set lightest = slot.mods[0] if slot.mods else none %}
|
{{ slot_card(slot) }}
|
||||||
<div class="slot-card" id="slot-{{ slot.slot_id }}">
|
|
||||||
<div class="slot-header" onclick="toggleSlot('slot-{{ slot.slot_id }}')">
|
|
||||||
<span class="slot-name">{{ slot.slot_name }}</span>
|
|
||||||
{% if slot.required %}<span class="required-badge">required</span>{% endif %}
|
|
||||||
<span class="slot-count">{{ slot.mods | length }} mods</span>
|
|
||||||
<span class="slot-lightest">
|
|
||||||
{% if lightest and lightest.weight_kg is not none %}
|
|
||||||
lightest {{ "%.3f"|format(lightest.weight_kg) }} kg
|
|
||||||
{% else %}—{% endif %}
|
|
||||||
</span>
|
|
||||||
<span class="chevron">▶</span>
|
|
||||||
</div>
|
|
||||||
<div class="mod-list">
|
|
||||||
{% if slot.mods %}
|
|
||||||
{% for mod in slot.mods %}
|
|
||||||
<div class="mod-row">
|
|
||||||
{% if mod.grid_image_url %}
|
|
||||||
<img src="{{ mod.grid_image_url }}" loading="lazy" alt="">
|
|
||||||
{% else %}
|
|
||||||
<div style="width:36px;height:36px;background:#222;border-radius:4px;flex-shrink:0"></div>
|
|
||||||
{% endif %}
|
|
||||||
<div class="mod-name">
|
|
||||||
{{ mod.mod_name }}
|
|
||||||
{% if mod.mod_short and mod.mod_short != mod.mod_name %}
|
|
||||||
<small>{{ mod.mod_short }}</small>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% if mod.wiki_url %}
|
|
||||||
<a class="mod-wiki" href="{{ mod.wiki_url }}" target="_blank">wiki</a>
|
|
||||||
{% endif %}
|
|
||||||
<span class="mod-weight {% if loop.first %}lightest{% endif %}">
|
|
||||||
{% if mod.weight_kg is not none %}{{ "%.3f"|format(mod.weight_kg) }} kg{% else %}—{% endif %}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% else %}
|
|
||||||
<div class="no-mods">No compatible mods found in database.</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -311,8 +280,8 @@
|
|||||||
const div = document.getElementById('other-slots');
|
const div = document.getElementById('other-slots');
|
||||||
div.classList.toggle('visible');
|
div.classList.toggle('visible');
|
||||||
btn.textContent = div.classList.contains('visible')
|
btn.textContent = div.classList.contains('visible')
|
||||||
? 'Hide other slots ▲'
|
? 'Hide optional slots ▲'
|
||||||
: 'Show {{ other_slots | length }} other slots ▼';
|
: 'Show {{ optional_slots | length }} optional slots ▼';
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -231,7 +231,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="tab-bar">
|
<div class="tab-bar">
|
||||||
{% for t_id, t_label in [('guns','Guns'),('armor','Armor'),('helmets','Helmets'),('headwear','Headwear'),('backpacks','Backpacks'),('rigs','Rigs'),('builder','Build Builder')] %}
|
{% for t_id, t_label in [('guns','Guns'),('armor','Armor'),('helmets','Helmets'),('headwear','Headwear'),('backpacks','Backpacks'),('rigs','Rigs'),('plates','Plates'),('builder','Build Builder')] %}
|
||||||
<a href="/loadout?tab={{ t_id }}" class="{% if tab == t_id %}active{% endif %}">{{ t_label }}</a>
|
<a href="/loadout?tab={{ t_id }}" class="{% if tab == t_id %}active{% endif %}">{{ t_label }}</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
@@ -328,6 +328,7 @@
|
|||||||
border-radius: 6px; padding: 5px 10px; font-size: 0.8rem; min-width: 110px;
|
border-radius: 6px; padding: 5px 10px; font-size: 0.8rem; min-width: 110px;
|
||||||
}
|
}
|
||||||
.slot-pill.key { border-color: #5a7a3a; background: #141e10; }
|
.slot-pill.key { border-color: #5a7a3a; background: #141e10; }
|
||||||
|
.slot-pill.optional { opacity: 0.55; }
|
||||||
.slot-pill .sp-name { color: var(--muted); font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em; }
|
.slot-pill .sp-name { color: var(--muted); font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||||
.slot-pill .sp-mod { font-size: 0.82rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 150px; }
|
.slot-pill .sp-mod { font-size: 0.82rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 150px; }
|
||||||
.slot-pill .sp-w { color: var(--amber); font-size: 0.82rem; font-weight: bold; margin-top: 2px; }
|
.slot-pill .sp-w { color: var(--amber); font-size: 0.82rem; font-weight: bold; margin-top: 2px; }
|
||||||
@@ -382,23 +383,39 @@
|
|||||||
const baseCell = document.querySelector('[data-gun-id="' + gunId + '"] td:nth-child(6)');
|
const baseCell = document.querySelector('[data-gun-id="' + gunId + '"] td:nth-child(6)');
|
||||||
if (baseCell) baseWeight = parseFloat(baseCell.textContent) || 0;
|
if (baseCell) baseWeight = parseFloat(baseCell.textContent) || 0;
|
||||||
|
|
||||||
|
// Only sum required slots for the lightest build weight
|
||||||
let total = baseWeight;
|
let total = baseWeight;
|
||||||
let pills = '';
|
let reqPills = '';
|
||||||
|
let optPills = '';
|
||||||
for (const s of slots) {
|
for (const s of slots) {
|
||||||
const isKey = KEY.has(s.slot_nameid);
|
const isKey = KEY.has(s.slot_nameid);
|
||||||
const w = s.weight_kg != null ? s.weight_kg.toFixed(3) + ' kg' : '—';
|
const w = s.weight_kg != null ? s.weight_kg.toFixed(3) + ' kg' : '—';
|
||||||
|
if (s.required) {
|
||||||
if (s.weight_kg != null) total += s.weight_kg;
|
if (s.weight_kg != null) total += s.weight_kg;
|
||||||
pills += `<div class="slot-pill${isKey ? ' key' : ''}">
|
reqPills += `<div class="slot-pill${isKey ? ' key' : ''}">
|
||||||
|
<span class="sp-name">${s.slot_name}</span>
|
||||||
|
<span class="sp-mod">${s.mod_name || '—'}</span>
|
||||||
|
<span class="sp-w">${w}</span>
|
||||||
|
</div>`;
|
||||||
|
} else {
|
||||||
|
optPills += `<div class="slot-pill optional">
|
||||||
<span class="sp-name">${s.slot_name}</span>
|
<span class="sp-name">${s.slot_name}</span>
|
||||||
<span class="sp-mod">${s.mod_name || '—'}</span>
|
<span class="sp-mod">${s.mod_name || '—'}</span>
|
||||||
<span class="sp-w">${w}</span>
|
<span class="sp-w">${w}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const optSection = optPills
|
||||||
|
? `<div style="font-size:0.75rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.06em;margin:8px 0 4px">Optional slots (not included in weight)</div>
|
||||||
|
<div class="slot-summary">${optPills}</div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
inner.innerHTML = `
|
inner.innerHTML = `
|
||||||
<div class="slot-summary">${pills}</div>
|
<div class="slot-summary">${reqPills || '<span style="color:var(--muted);font-size:0.82rem">No required slots</span>'}</div>
|
||||||
|
${optSection}
|
||||||
<div class="expand-footer">
|
<div class="expand-footer">
|
||||||
<span class="expand-total">Lightest build: ${total.toFixed(3)} kg</span>
|
<span class="expand-total">Lightest build (required slots): ${total.toFixed(3)} kg</span>
|
||||||
<a class="expand-link" href="/loadout/gun/${gunId}">Full breakdown →</a>
|
<a class="expand-link" href="/loadout/gun/${gunId}">Full breakdown →</a>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -450,7 +467,10 @@
|
|||||||
<td class="muted">{{ item.durability | int if item.durability else '—' }}</td>
|
<td class="muted">{{ item.durability | int if item.durability else '—' }}</td>
|
||||||
<td class="muted">{{ item.material or '—' }}</td>
|
<td class="muted">{{ item.material or '—' }}</td>
|
||||||
<td class="muted" style="font-size:0.8rem">{{ item.zones or '—' }}</td>
|
<td class="muted" style="font-size:0.8rem">{{ item.zones or '—' }}</td>
|
||||||
<td class="w">{% if item.weight_kg is not none %}{{ "%.3f"|format(item.weight_kg) }} kg{% else %}—{% endif %}</td>
|
<td class="w">
|
||||||
|
{% if item.weight_kg is not none %}{{ "%.3f"|format(item.weight_kg) }} kg{% else %}—{% endif %}
|
||||||
|
{% if item.id in carrier_ids_with_open_slots %}<br><small class="muted">no plates</small>{% endif %}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr><td colspan="7" class="empty">No armor found.</td></tr>
|
<tr><td colspan="7" class="empty">No armor found.</td></tr>
|
||||||
@@ -653,7 +673,10 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="muted">{{ item.capacity or '—' }}</td>
|
<td class="muted">{{ item.capacity or '—' }}</td>
|
||||||
<td class="muted" style="font-size:0.8rem">{{ item.zones or '—' }}</td>
|
<td class="muted" style="font-size:0.8rem">{{ item.zones or '—' }}</td>
|
||||||
<td class="w">{% if item.weight_kg is not none %}{{ "%.3f"|format(item.weight_kg) }} kg{% else %}—{% endif %}</td>
|
<td class="w">
|
||||||
|
{% if item.weight_kg is not none %}{{ "%.3f"|format(item.weight_kg) }} kg{% else %}—{% endif %}
|
||||||
|
{% if item.id in carrier_ids_with_open_slots %}<br><small class="muted">no plates</small>{% endif %}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr><td colspan="6" class="empty">No rigs found.</td></tr>
|
<tr><td colspan="6" class="empty">No rigs found.</td></tr>
|
||||||
@@ -662,6 +685,63 @@
|
|||||||
</table>
|
</table>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{# =============================== PLATES TAB =============================== #}
|
||||||
|
{% if tab == "plates" %}
|
||||||
|
<p style="color:var(--muted);font-size:0.85rem;margin-bottom:12px;">
|
||||||
|
Armor plates that slot into plate carriers. Carrier shell weight does <em>not</em> include plates — add them separately when building your loadout.
|
||||||
|
</p>
|
||||||
|
<form method="get" class="filter-bar">
|
||||||
|
<input type="hidden" name="tab" value="plates">
|
||||||
|
<label>Min class</label>
|
||||||
|
<select name="min_class">
|
||||||
|
<option value="0" {% if min_class==0 %}selected{% endif %}>Any</option>
|
||||||
|
{% for c in range(1,7) %}
|
||||||
|
<option value="{{ c }}" {% if min_class==c %}selected{% endif %}>Class {{ c }}+</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<label>Sort</label>
|
||||||
|
<select name="sort">
|
||||||
|
<option value="weight_asc" {% if sort=='weight_asc' %}selected{% endif %}>Weight ↑</option>
|
||||||
|
<option value="weight_desc" {% if sort=='weight_desc' %}selected{% endif %}>Weight ↓</option>
|
||||||
|
<option value="class_desc" {% if sort=='class_desc' %}selected{% endif %}>Class ↓</option>
|
||||||
|
<option value="class_asc" {% if sort=='class_asc' %}selected{% endif %}>Class ↑</option>
|
||||||
|
<option value="name_asc" {% if sort=='name_asc' %}selected{% endif %}>Name A→Z</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit">Filter</button>
|
||||||
|
</form>
|
||||||
|
<table class="gear-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th></th><th>Name</th><th>Class</th>
|
||||||
|
<th>Durability</th><th>Material</th><th>Zones</th><th>Weight</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in plates %}
|
||||||
|
<tr>
|
||||||
|
<td>{% if item.grid_image_url %}<img src="{{ item.grid_image_url }}" loading="lazy" alt="">{% endif %}</td>
|
||||||
|
<td class="name-cell">
|
||||||
|
<strong>{{ item.short_name or item.name }}</strong>
|
||||||
|
{% if item.short_name and item.short_name != item.name %}<small>{{ item.name }}</small>{% endif %}
|
||||||
|
{% if item.wiki_url %}<a href="{{ item.wiki_url }}" target="_blank" style="font-size:0.78rem;margin-left:4px">wiki</a>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if item.armor_class %}
|
||||||
|
<span class="cls cls-{{ item.armor_class }}">{{ item.armor_class }}</span>
|
||||||
|
{% else %}—{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="muted">{{ item.durability | int if item.durability else '—' }}</td>
|
||||||
|
<td class="muted">{{ item.material or '—' }}</td>
|
||||||
|
<td class="muted" style="font-size:0.8rem">{{ item.zones or '—' }}</td>
|
||||||
|
<td class="w">{% if item.weight_kg is not none %}{{ "%.3f"|format(item.weight_kg) }} kg{% else %}—{% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="7" class="empty">No plates found.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{# =============================== BUILD BUILDER TAB =============================== #}
|
{# =============================== BUILD BUILDER TAB =============================== #}
|
||||||
{% if tab == "builder" %}
|
{% if tab == "builder" %}
|
||||||
<script>
|
<script>
|
||||||
@@ -671,6 +751,15 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// carriers that have open plate slots (shell weight only)
|
||||||
|
const CARRIERS_WITH_OPEN_SLOTS = new Set({{ carrier_ids_with_open_slots | list | tojson }});
|
||||||
|
|
||||||
|
// plate weight cache: id -> weight_kg
|
||||||
|
const PLATE_WEIGHTS = {};
|
||||||
|
|
||||||
|
// currently selected plate weights per open slot, keyed by "carrierSlot|slotNameId"
|
||||||
|
const _plateSlotWeights = {};
|
||||||
|
|
||||||
function recalcWeight() {
|
function recalcWeight() {
|
||||||
const slots = ['gun', 'armor', 'helmet', 'rig', 'backpack'];
|
const slots = ['gun', 'armor', 'helmet', 'rig', 'backpack'];
|
||||||
let total = 0;
|
let total = 0;
|
||||||
@@ -679,12 +768,85 @@
|
|||||||
const id = sel ? sel.value : '';
|
const id = sel ? sel.value : '';
|
||||||
const w = id ? (WEIGHTS[id] || 0) : 0;
|
const w = id ? (WEIGHTS[id] || 0) : 0;
|
||||||
const disp = document.getElementById('sw_' + slot);
|
const disp = document.getElementById('sw_' + slot);
|
||||||
if (disp) disp.textContent = id ? w.toFixed(3) + ' kg' : '';
|
if (disp) {
|
||||||
|
if (id) {
|
||||||
|
const isCarrier = CARRIERS_WITH_OPEN_SLOTS.has(id);
|
||||||
|
disp.textContent = w.toFixed(3) + ' kg' + (isCarrier ? ' (shell only)' : '');
|
||||||
|
} else {
|
||||||
|
disp.textContent = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
total += w;
|
total += w;
|
||||||
}
|
}
|
||||||
|
// Add plate weights
|
||||||
|
for (const pw of Object.values(_plateSlotWeights)) {
|
||||||
|
total += pw;
|
||||||
|
}
|
||||||
document.getElementById('total-weight').textContent = total.toFixed(3);
|
document.getElementById('total-weight').textContent = total.toFixed(3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const _carrierSlotCache = {};
|
||||||
|
|
||||||
|
function onCarrierChange(slot) {
|
||||||
|
const sel = document.getElementById('slot_' + slot);
|
||||||
|
const carrierId = sel ? sel.value : '';
|
||||||
|
const container = document.getElementById('plates_' + slot);
|
||||||
|
container.innerHTML = '';
|
||||||
|
// Clear plate slot weights for this carrier slot
|
||||||
|
for (const key of Object.keys(_plateSlotWeights)) {
|
||||||
|
if (key.startsWith(slot + '|')) delete _plateSlotWeights[key];
|
||||||
|
}
|
||||||
|
if (!carrierId || !CARRIERS_WITH_OPEN_SLOTS.has(carrierId)) {
|
||||||
|
recalcWeight();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_carrierSlotCache[carrierId]) {
|
||||||
|
renderPlateSlots(slot, carrierId, _carrierSlotCache[carrierId]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetch('/loadout/carrier/' + carrierId + '/slots.json')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
_carrierSlotCache[carrierId] = data;
|
||||||
|
renderPlateSlots(slot, carrierId, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPlateSlots(carrierSlot, carrierId, slots) {
|
||||||
|
const container = document.getElementById('plates_' + carrierSlot);
|
||||||
|
container.innerHTML = '';
|
||||||
|
for (const slot of slots) {
|
||||||
|
const key = carrierSlot + '|' + slot.slot_nameid;
|
||||||
|
const label = document.createElement('label');
|
||||||
|
label.style.cssText = 'display:block;margin-top:8px;font-size:0.82rem;color:var(--muted)';
|
||||||
|
label.textContent = slot.slot_nameid.replace(/_/g, ' ') + (slot.zones ? ' (' + slot.zones + ')' : '');
|
||||||
|
const sel = document.createElement('select');
|
||||||
|
sel.style.cssText = 'width:100%;margin-top:2px';
|
||||||
|
const none = document.createElement('option');
|
||||||
|
none.value = '';
|
||||||
|
none.textContent = '— No plate —';
|
||||||
|
sel.appendChild(none);
|
||||||
|
for (const p of slot.plates) {
|
||||||
|
PLATE_WEIGHTS[p.id] = p.weight_kg || 0;
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = p.id;
|
||||||
|
opt.textContent = (p.short_name || p.name) +
|
||||||
|
' (Cls ' + (p.armor_class || '?') + ', ' +
|
||||||
|
(p.weight_kg != null ? p.weight_kg.toFixed(3) : '?') + ' kg)';
|
||||||
|
sel.appendChild(opt);
|
||||||
|
}
|
||||||
|
sel.addEventListener('change', () => {
|
||||||
|
const pid = sel.value;
|
||||||
|
_plateSlotWeights[key] = pid ? (PLATE_WEIGHTS[pid] || 0) : 0;
|
||||||
|
recalcWeight();
|
||||||
|
});
|
||||||
|
container.appendChild(label);
|
||||||
|
container.appendChild(sel);
|
||||||
|
_plateSlotWeights[key] = 0;
|
||||||
|
}
|
||||||
|
recalcWeight();
|
||||||
|
}
|
||||||
|
|
||||||
function saveBuild() {
|
function saveBuild() {
|
||||||
const slots = ['gun', 'armor', 'helmet', 'rig', 'backpack'];
|
const slots = ['gun', 'armor', 'helmet', 'rig', 'backpack'];
|
||||||
const payload = { name: document.getElementById('build-name').value.trim() || 'My Build' };
|
const payload = { name: document.getElementById('build-name').value.trim() || 'My Build' };
|
||||||
@@ -714,24 +876,25 @@
|
|||||||
|
|
||||||
<div class="builder-grid">
|
<div class="builder-grid">
|
||||||
{% set slot_defs = [
|
{% set slot_defs = [
|
||||||
('gun', 'Primary Weapon', builder_guns),
|
('gun', 'Primary Weapon', builder_guns, false),
|
||||||
('armor', 'Body Armor', builder_armor),
|
('armor', 'Body Armor', builder_armor, true),
|
||||||
('helmet', 'Helmet', builder_helmets),
|
('helmet', 'Helmet', builder_helmets, false),
|
||||||
('rig', 'Chest Rig', builder_rigs),
|
('rig', 'Chest Rig', builder_rigs, true),
|
||||||
('backpack', 'Backpack', builder_backpacks),
|
('backpack', 'Backpack', builder_backpacks, false),
|
||||||
] %}
|
] %}
|
||||||
{% for slot_id, slot_label, items in slot_defs %}
|
{% for slot_id, slot_label, items, has_plates in slot_defs %}
|
||||||
<div class="slot-card">
|
<div class="slot-card">
|
||||||
<h3>{{ slot_label }}</h3>
|
<h3>{{ slot_label }}</h3>
|
||||||
<select id="slot_{{ slot_id }}" onchange="recalcWeight()">
|
<select id="slot_{{ slot_id }}" onchange="{% if has_plates %}onCarrierChange('{{ slot_id }}'){% else %}recalcWeight(){% endif %}">
|
||||||
<option value="">— None —</option>
|
<option value="">— None —</option>
|
||||||
{% for item in items %}
|
{% for item in items %}
|
||||||
<option value="{{ item.id }}">
|
<option value="{{ item.id }}">
|
||||||
{{ item.name }}{% if item.weight_kg is not none %} ({{ "%.3f"|format(item.weight_kg) }} kg){% endif %}
|
{{ item.name }}{% if item.weight_kg is not none %} ({{ "%.3f"|format(item.weight_kg) }} kg{% if item.id in carrier_ids_with_open_slots %} shell{% endif %}){% endif %}
|
||||||
</option>
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<div class="slot-weight" id="sw_{{ slot_id }}"></div>
|
<div class="slot-weight" id="sw_{{ slot_id }}"></div>
|
||||||
|
{% if has_plates %}<div id="plates_{{ slot_id }}"></div>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user