Compare commits

...

9 Commits

Author SHA1 Message Date
serversdwn
b5a5755b6f feat: adds efficiency calculation for rigs and back pack (WIP). UX cleaned up, graphic added to landing page. 2026-03-29 04:56:36 +00:00
serversdwn
90f2601c1d feat: add barter calculator page 2026-03-26 05:04:13 +00:00
serversdwn
7650633af4 chore: update gear db via tarkov.dev api pull. 2026-03-26 03:37:08 +00:00
serversdwn
289c45e233 feat: add landing page, Improved key rating page UI 2026-03-08 21:17:50 +00:00
serversdwn
4893f3deee feat: Added new injector chart page. 2026-03-01 21:49:32 +00:00
serversdwn
394c7ebde7 fix: quest tree cleaned up a bit, Key's ratings not saving fixed 2026-02-25 05:53:16 +00:00
serversdwn
9d572f5d15 Add: armor plates support with new database tables and loadout functionality
fix: only required mods are applied to guns for weight management.
2026-02-24 16:08:58 +00:00
serversdwn
84768ae587 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.
2026-02-22 08:51:28 +00:00
serversdwn
68005b1cb0 v0.1.1 -
feat: collector task checklist.
chore: docs updated, gitignore updated.
Docs: TARKOV_DEV_API.md fully explains tarkov.dev's api for future coding agents and forgetful people llike me.
2026-02-21 09:41:06 +00:00
21 changed files with 6364 additions and 45 deletions

7
.claude/settings.json Normal file
View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(curl:*)"
]
}
}

34
.gitignore vendored Normal file
View File

@@ -0,0 +1,34 @@
# Database — source of truth lives locally, not in git
*.db
*.db-shm
*.db-wal
# Python
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
*.egg-info/
dist/
build/
.eggs/
# Virtual environments
venv/
.venv/
env/
# Editor / IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Secrets / env
.env
*.env

120
README.md
View File

@@ -1,14 +1,112 @@
##Place hoder, for this here personalized tarkov DB im building. # OnlyScavs v0.2
A personal Escape from Tarkov database and toolkit. The goal is to maintain a **local SQLite database that I fully control** — tarkov.dev is used only as a one-time (or on-demand) data source to seed it. Once imported, the local DB is the source of truth and can be edited, annotated, and extended freely without relying on any external API being up or accurate.
##Items to be tracked = ## ---
**Weight Management Mostly**
--Weapons and weapons parts
--helmets + armor and rigs
--backpacks
**Keys** ## What it does
--full list w/ locations and whats behind the lock
-- vendor price - **Key tracker** — full list of keys with personal priority ratings (IGNORE / LOW / MED / HIGH / SUPER), map tagging, notes, and quest flags
-- My personal 0-4 priority scale. - **Collector checklist** — all 255 quests required to unlock *The Collector* (Kappa), with per-quest done/not-done tracking and a progress bar
-- Flag useless keys.
---
## Setup
### 1. Install dependencies
```bash
pip install flask requests
```
### 2. Initialize the database
Run the imports in order. Each script creates its own tables if they don't exist.
```bash
# Import all keys from tarkov.dev into local DB
python3 import_keys.py
# Import all quests/tasks and their dependency graph
python3 import_quests.py
```
Then apply the maps migration (adds maps table + keymap relationships):
```bash
python3 -c "
import sqlite3
conn = sqlite3.connect('tarkov.db')
conn.executescript(open('migrations_v1.sql').read())
conn.commit()
conn.close()
"
```
> After this, the DB is yours. You don't need tarkov.dev running to use the app.
### 3. Run the app
```bash
python3 app.py
```
Open **http://127.0.0.1:5000**
---
## Re-importing data
The import scripts can be re-run any time to pull fresh data from tarkov.dev (e.g. after a big patch). They use `INSERT OR REPLACE` / `INSERT OR IGNORE` so they won't duplicate records, but **any manual edits to imported fields (name, wiki_link, etc.) will be overwritten**. Personal data (ratings, notes, map tags, quest progress) is stored in separate tables and is safe.
```bash
python3 import_keys.py # refresh key list
python3 import_quests.py # refresh quest list + dependency graph
```
---
## Project structure
```
onlyscavs/
├── app.py # Flask web app
├── import_keys.py # Seeds keys table from tarkov.dev
├── import_quests.py # Seeds quests + quest_deps tables from tarkov.dev
├── migrations_v1.sql # Maps table + key_maps + used_in_quest flag
├── tarkov.db # Local SQLite DB (gitignored — stays on your machine)
├── templates/
│ ├── index.html # Key ratings UI
│ └── collector.html # Collector checklist UI
└── TARKOV_DEV_API.md # tarkov.dev GraphQL API reference
```
---
## Database schema
| Table | Purpose |
|---|---|
| `keys` | All key items (seeded from tarkov.dev, then local) |
| `key_ratings` | Personal ratings, notes, quest flags per key |
| `key_maps` | Which maps each key is used on |
| `maps` | Map list |
| `quests` | All tasks/quests (seeded from tarkov.dev) |
| `quest_deps` | Quest prerequisite graph (which quest unlocks which) |
| `quest_progress` | Personal done/not-done state per quest |
---
## Routes
| Route | Description |
|---|---|
| `GET /` | Key list with filters, sorting, and inline rating forms |
| `POST /rate` | Save rating for a single key |
| `POST /rate_all` | Save ratings for all visible keys |
| `GET /collector` | Collector checklist with progress bar |
| `POST /collector/toggle` | Mark a quest done or not done |
---
## Planned
- Weight/loadout tracker (weapons, armor, rigs, backpacks)
- Vendor price comparison
- Key location notes (what's behind the door)

458
TARKOV_DEV_API.md Normal file
View File

@@ -0,0 +1,458 @@
# tarkov.dev GraphQL API Reference
> Community-made, real-time EFT data. No API key required.
- **Endpoint:** `https://api.tarkov.dev/graphql`
- **Playground:** `https://api.tarkov.dev/` (interactive explorer with autocomplete)
- **Protocol:** GraphQL over HTTP POST
- **Auth:** None — completely open
- **Source:** https://github.com/the-hideout/tarkov-api
---
## How to query (Python)
```python
import requests
API_URL = "https://api.tarkov.dev/graphql"
def gql(query):
r = requests.post(API_URL, json={"query": query})
r.raise_for_status()
data = r.json()
if "errors" in data:
raise RuntimeError(data["errors"])
return data["data"]
```
---
## Common arguments (most queries accept these)
| Arg | Type | Notes |
|------------|----------------|--------------------------------------------|
| `lang` | `LanguageCode` | e.g. `en`, `ru`, `de`, `fr`, `es`, `zh` |
| `gameMode` | `GameMode` | `regular` or `pve` |
| `limit` | `Int` | Max results to return |
| `offset` | `Int` | Pagination offset |
---
## All available root queries
```
achievements(lang, limit, offset)
ammo(lang, gameMode, limit, offset)
archivedItemPrices(id, limit, offset)
barters(lang, gameMode, limit, offset)
bosses(lang, gameMode, name, limit, offset)
crafts(lang, gameMode, limit, offset)
fleaMarket(lang, gameMode)
goonReports(lang, gameMode, limit, offset)
handbookCategories(lang, limit, offset)
hideoutStations(lang, gameMode, limit, offset)
historicalItemPrices(id, days, lang, gameMode, limit, offset)
item(id, normalizedName, lang, gameMode)
items(ids, name, names, type, types, categoryNames, handbookCategoryNames, bsgCategoryId, bsgCategoryIds, bsgCategory, lang, gameMode, limit, offset)
itemCategories(lang, limit, offset)
itemPrices(id, gameMode, limit, offset)
lootContainers(lang, limit, offset)
maps(lang, gameMode, name, enemies, limit, offset)
mastering(lang)
playerLevels()
prestige(lang, gameMode)
questItems(lang)
skills(lang)
stationaryWeapons(lang, limit, offset)
status()
task(id, lang, gameMode)
tasks(faction, lang, gameMode, limit, offset)
traders(lang, gameMode, limit, offset)
```
---
## Query examples
### Server status
```graphql
{
status {
currentStatuses {
name
message
status
}
messages {
time
type
content
solveTime
}
}
}
```
### Single item by name
```graphql
{
items(name: "colt m4a1") {
id
name
shortName
basePrice
avg24hPrice
changeLast48hPercent
width
height
weight
wikiLink
iconLink
gridImageLink
sellFor {
price
currency
source
}
buyFor {
price
currency
source
}
}
}
```
### Items by type (e.g. all keys)
```graphql
{
items(types: [keys]) {
id
name
shortName
wikiLink
gridImageLink
properties {
... on ItemPropertiesKey {
uses
}
}
}
}
```
Valid `ItemType` values include: `keys`, `ammo`, `armor`, `backpack`, `gun`, `headwear`, `rig`, `medical`, `food`, `barter`, `container`, `grenade`, `headphones`, `knife`, `stimulator`, `suppressor`, `weapon`
### All tasks / quests
```graphql
{
tasks {
id
name
wikiLink
minPlayerLevel
kappaRequired
trader {
name
}
taskRequirements {
task {
id
name
}
status
}
objectives {
id
type
description
maps {
name
normalizedName
}
... on TaskObjectiveItem {
item { name shortName }
count
foundInRaid
}
... on TaskObjectiveShoot {
targetNames
count
}
... on TaskObjectiveLocation {
locationNames
}
... on TaskObjectiveQuestItem {
questItem { name }
count
}
... on TaskObjectiveSkill {
skillLevel { name level }
}
... on TaskObjectiveTraderLevel {
trader { name }
level
}
}
startRewards {
items { item { name } count }
traderStanding { trader { name } standing }
}
finishRewards {
items { item { name } count }
experience
traderStanding { trader { name } standing }
offerUnlock { trader { name } item { name } }
}
}
}
```
> **Note:** `objectives` returns a `TaskObjective` **interface** — use inline fragments (`... on TypeName`) to access type-specific fields.
### Single task by ID
```graphql
{
task(id: "5936d90786f7742b1420ba5b") {
name
trader { name }
taskRequirements { task { id name } }
}
}
```
### Ammo stats
```graphql
{
ammo {
item { name shortName }
caliber
damage
armorDamage
penetrationPower
penetrationChance
fragmentationChance
initialSpeed
lightBleedModifier
heavyBleedModifier
}
}
```
### Traders
```graphql
{
traders {
id
name
normalizedName
currency { name }
levels {
level
requiredPlayerLevel
requiredReputation
requiredCommerce
cashOffers {
item { name }
price
currency
minTraderLevel
taskUnlock { name }
}
}
barters {
requiredItems { item { name } count }
rewardItems { item { name } count }
minTraderLevel
taskUnlock { name }
}
}
}
```
### Hideout stations
```graphql
{
hideoutStations {
id
name
levels {
level
constructionTime
itemRequirements { item { name } count }
stationLevelRequirements { station { name } level }
skillRequirements { skill { name } level }
traderRequirements { trader { name } requirementType value }
bonuses { type value }
}
}
}
```
### Maps
```graphql
{
maps {
id
name
normalizedName
raidDuration
players
bosses {
boss { name }
spawnChance
spawnLocations { name chance }
escorts { boss { name } amount { count chance } }
}
extracts {
name
faction
switches { name }
}
}
}
```
### Crafts
```graphql
{
crafts {
station { name }
level
duration
requiredItems { item { name } count }
rewardItems { item { name } count }
taskUnlock { name }
}
}
```
### Barters
```graphql
{
barters {
trader { name }
level
taskUnlock { name }
requiredItems { item { name } count }
rewardItems { item { name } count }
}
}
```
### Flea market info
```graphql
{
fleaMarket {
enabled
minPlayerLevel
sellOfferFeeRate
sellRequirementFeeRate
}
}
```
### Achievements
```graphql
{
achievements {
id
name
description
hidden
rarity
playersCompletedPercent
side
}
}
```
### Goon reports (roaming boss locations)
```graphql
{
goonReports {
map { name }
timestamp
}
}
```
---
## Key types reference
### Item fields
```
id, name, shortName, normalizedName
basePrice, avg24hPrice, low24hPrice, high24hPrice
changeLast48h, changeLast48hPercent
lastLowPrice, lastOfferCount
width, height, weight, velocity
wikiLink, iconLink, gridImageLink, inspectImageLink, image8xLink
types[] ItemType enum values this item belongs to
sellFor[] { price, currency, source, vendor { name } }
buyFor[] { price, currency, source, vendor { name } }
bartersFor[] Barter objects
bartersUsing[] Barter objects
usedInTasks[] Task objects
receivedFromTasks[] Task objects
properties ItemProperties union (see below)
category { id, name }
```
### ItemProperties union types
Use `... on ItemPropertiesX { }` inline fragments:
- `ItemPropertiesKey``uses`
- `ItemPropertiesAmmo``caliber, damage, penetrationPower, ...`
- `ItemPropertiesArmor``class, durability, material, zones[]`
- `ItemPropertiesArmorAttachment``class, durability, material, zones[], headCoverage`
- `ItemPropertiesBackpack``capacity, grids[]`
- `ItemPropertiesChestRig``capacity, class, durability, zones[]`
- `ItemPropertiesWeapon``caliber, fireRate, ergonomics, recoil...`
- `ItemPropertiesMagazine``capacity, caliber, ergonomics`
- `ItemPropertiesScope``ergonomics, recoil, zoomLevels[]`
- `ItemPropertiesMedKit``uses, useTime, hpCostLightBleeding, hpCostHeavyBleeding`
- `ItemPropertiesFood``energy, hydration, stimEffects[]`
- `ItemPropertiesStimulator``stimEffects[]`
- `ItemPropertiesHelmet``class, durability, material, headZones[], deafening`
- `ItemPropertiesGlasses``class, durability, blindnessProtection`
- `ItemPropertiesNightVision``intensity, noiseIntensity, noiseScale`
- `ItemPropertiesContainer``capacity`
- `ItemPropertiesGrenade``type, fuse, fragments, minExplosionDistance, maxExplosionDistance`
### Task / quest fields
```
id, name, normalizedName
wikiLink
minPlayerLevel
kappaRequired bool, whether needed for Kappa (The Collector)
restartable
trader { id, name }
map { id, name } nullable, map-specific tasks only
experience
taskRequirements[] { task { id, name }, status[] }
objectives[] TaskObjective interface (use inline fragments)
startRewards TaskRewards
finishRewards TaskRewards
```
### TaskRewards fields
```
items[] { item { name }, count }
traderStanding[] { trader { name }, standing }
offerUnlock[] { trader { name }, level, item { name } }
skillLevelReward[] { name, level }
craftUnlock[] craft objects
```
---
## How we use it in this project
| Script | Query | Purpose |
|---|---|---|
| [import_keys.py](import_keys.py) | `items(types: [keys])` | Fetches all key items into `keys` table |
| [import_quests.py](import_quests.py) | `tasks { id name wikiLink trader taskRequirements }` | Fetches all tasks and their dependencies into `quests` + `quest_deps` tables |
The Collector prerequisite tree is computed from `quest_deps` using a recursive SQL CTE in [app.py](app.py) at `/collector`.

925
app.py
View File

@@ -1,31 +1,85 @@
from flask import Flask, render_template, request, redirect, url_for from flask import Flask, render_template, request, redirect, url_for, jsonify
import sqlite3 import sqlite3
app = Flask(__name__) app = Flask(__name__, static_folder="assets", static_url_path="/assets")
DB_PATH = "tarkov.db" DB_PATH = "tarkov.db"
def get_db(): def get_db():
conn = sqlite3.connect(DB_PATH) conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return conn 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("/") @app.route("/")
def index(): def landing():
return render_template("landing.html")
def _keys_context():
conn = get_db() conn = get_db()
maps = conn.execute(""" maps = conn.execute("SELECT id, name FROM maps ORDER BY name").fetchall()
SELECT id, name
FROM maps
ORDER BY name
""").fetchall()
map_filter = request.args.get("map_id", type=int) map_filter = request.args.get("map_id", type=int)
sort = request.args.get("sort", "priority_desc") sort = request.args.get("sort", "priority_desc")
show = request.args.get("show", "all") show = request.args.get("show", "all")
key_map_rows = conn.execute(""" key_map_rows = conn.execute("SELECT key_id, map_id FROM key_maps").fetchall()
SELECT key_id, map_id
FROM key_maps
""").fetchall()
key_maps = {} key_maps = {}
for row in key_map_rows: for row in key_map_rows:
key_maps.setdefault(row["key_id"], set()).add(row["map_id"]) key_maps.setdefault(row["key_id"], set()).add(row["map_id"])
@@ -50,9 +104,7 @@ def index():
AND kmf.map_id = ? AND kmf.map_id = ?
""" """
params.append(map_filter) params.append(map_filter)
key_query += """ key_query += " LEFT JOIN key_ratings r ON k.id = r.key_id "
LEFT JOIN key_ratings r ON k.id = r.key_id
"""
if show == "rated": if show == "rated":
key_query += " WHERE r.priority IS NOT NULL " key_query += " WHERE r.priority IS NOT NULL "
elif show == "unrated": elif show == "unrated":
@@ -74,15 +126,13 @@ def index():
conn.close() conn.close()
key_maps = {k: sorted(v) for k, v in key_maps.items()} key_maps = {k: sorted(v) for k, v in key_maps.items()}
return render_template( return dict(keys=keys, maps=maps, key_maps=key_maps,
"index.html", map_filter=map_filter, sort=sort, show=show)
keys=keys,
maps=maps,
key_maps=key_maps, @app.route("/keys")
map_filter=map_filter, def keys_page():
sort=sort, return render_template("keys.html", **_keys_context())
show=show,
)
@app.route("/rate", methods=["POST"]) @app.route("/rate", methods=["POST"])
@@ -129,7 +179,7 @@ def rate_key():
redirect_args["sort"] = sort redirect_args["sort"] = sort
if show: if show:
redirect_args["show"] = show redirect_args["show"] = show
base_url = url_for("index", **redirect_args) base_url = url_for("keys_page", **redirect_args)
return redirect(f"{base_url}#key-{key_id}") return redirect(f"{base_url}#key-{key_id}")
@@ -188,11 +238,834 @@ def rate_all():
redirect_args["sort"] = sort redirect_args["sort"] = sort
if show: if show:
redirect_args["show"] = show redirect_args["show"] = show
base_url = url_for("index", **redirect_args) base_url = url_for("keys_page", **redirect_args)
if save_one: if save_one:
return redirect(f"{base_url}#key-{save_one}") return redirect(f"{base_url}#key-{save_one}")
return redirect(base_url) 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__": if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=True) app.run(host="0.0.0.0", port=5000, debug=True)

BIN
assets/onlyscavs.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

652
import_gear.py Normal file
View File

@@ -0,0 +1,652 @@
import requests
import sqlite3
import sys
DB_PATH = "tarkov.db"
API_URL = "https://api.tarkov.dev/graphql"
# Slot nameId patterns that identify what type of mod goes in a slot
SLOT_TYPE_MAP = {
"mod_muzzle": "suppressor",
"mod_scope": "scope",
"mod_tactical": "flashlight",
"mod_tactical_001": "flashlight",
"mod_tactical_002": "flashlight",
"mod_tactical_003": "flashlight",
"mod_stock": "stock",
"mod_stock_000": "stock",
"mod_stock_001": "stock",
"mod_pistol_grip": "grip",
"mod_grip": "foregrip",
"mod_foregrip": "foregrip",
"mod_magazine": "magazine",
"mod_barrel": "barrel",
"mod_gas_block": "gas_block",
"mod_handguard": "handguard",
"mod_launcher": "launcher",
"mod_bipod": "bipod",
}
# Fetch weapons separately due to large slots/allowedItems payload
GRAPHQL_QUERY_WEAPONS = """
{
weapons: items(types: [gun]) {
id name shortName weight gridImageLink wikiLink
properties {
... on ItemPropertiesWeapon {
caliber
fireRate
ergonomics
recoilVertical
defaultWeight
slots {
id
name
nameId
required
filters {
allowedItems { id }
}
}
}
}
}
}
"""
GRAPHQL_QUERY_GEAR = """
{
armor: items(types: [armor]) {
id name shortName weight gridImageLink wikiLink
properties {
... on ItemPropertiesArmor {
class
durability
material { name }
zones
armorSlots {
__typename
... on ItemArmorSlotOpen {
nameId
zones
allowedPlates { id }
}
}
}
}
}
helmets: items(types: [helmet]) {
id name shortName weight gridImageLink wikiLink
properties {
... on ItemPropertiesHelmet {
class
durability
material { name }
headZones
deafening
}
}
}
wearables: items(types: [wearable]) {
id name shortName weight gridImageLink wikiLink
properties {
... on ItemPropertiesHelmet {
class
durability
material { name }
headZones
deafening
}
}
}
backpacks: items(types: [backpack]) {
id name shortName weight gridImageLink wikiLink
properties {
... on ItemPropertiesBackpack {
capacity
grids { width height }
}
}
}
rigs: items(types: [rig]) {
id name shortName weight gridImageLink wikiLink
properties {
... on ItemPropertiesChestRig {
capacity
class
durability
zones
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
}
}
}
suppressors: items(types: [suppressor]) {
id name shortName weight gridImageLink wikiLink
}
mods: items(types: [mods]) {
id name shortName weight gridImageLink wikiLink
}
}
"""
def gql(query, label="query"):
response = requests.post(
API_URL,
json={"query": query},
timeout=90
)
response.raise_for_status()
data = response.json()
if "errors" in data:
raise RuntimeError(f"{label} errors: {data['errors']}")
return data["data"]
def upsert_item(cursor, item_id, name, short_name, category, weight,
grid_image_url, wiki_url,
caliber=None, fire_rate=None, ergonomics=None, recoil_vertical=None,
default_weight=None, armor_class=None, durability=None, material=None,
zones=None, head_zones=None, deafening=None, capacity=None, mod_type=None):
cursor.execute("SELECT id FROM gear_items WHERE api_id = ?", (item_id,))
existing = cursor.fetchone()
if existing:
cursor.execute("""
UPDATE gear_items
SET name=?, short_name=?, category=?, weight_kg=?, grid_image_url=?, wiki_url=?,
caliber=?, fire_rate=?, ergonomics=?, recoil_vertical=?, default_weight=?,
armor_class=?, durability=?, material=?, zones=?, head_zones=?, deafening=?,
capacity=?, mod_type=?, updated_at=CURRENT_TIMESTAMP
WHERE api_id=?
""", (name, short_name, category, weight, grid_image_url, wiki_url,
caliber, fire_rate, ergonomics, recoil_vertical, default_weight,
armor_class, durability, material, zones, head_zones, deafening,
capacity, mod_type, item_id))
return "updated"
else:
cursor.execute("""
INSERT INTO gear_items
(id, api_id, name, short_name, category, weight_kg, grid_image_url, wiki_url,
caliber, fire_rate, ergonomics, recoil_vertical, default_weight,
armor_class, durability, material, zones, head_zones, deafening,
capacity, mod_type)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
""", (item_id, item_id, name, short_name, category, weight, grid_image_url, wiki_url,
caliber, fire_rate, ergonomics, recoil_vertical, default_weight,
armor_class, durability, material, zones, head_zones, deafening,
capacity, mod_type))
return "inserted"
def import_weapons(conn, weapons):
cursor = conn.cursor()
counts = {"inserted": 0, "updated": 0, "skipped": 0}
# Collect slot data for phase 2
slot_data = {} # gun_id -> list of slot dicts
for w in weapons:
item_id = w.get("id")
name = w.get("name")
if not item_id or not name:
counts["skipped"] += 1
continue
props = w.get("properties") or {}
result = upsert_item(
cursor, item_id, name, w.get("shortName"), "gun", w.get("weight"),
w.get("gridImageLink"), w.get("wikiLink"),
caliber=props.get("caliber"),
fire_rate=props.get("fireRate"),
ergonomics=props.get("ergonomics"),
recoil_vertical=props.get("recoilVertical"),
default_weight=props.get("defaultWeight"),
)
counts[result] += 1
slots = props.get("slots") or []
if slots:
slot_data[item_id] = slots
conn.commit()
return counts, slot_data
def import_armor(conn, items):
cursor = conn.cursor()
counts = {"inserted": 0, "updated": 0, "skipped": 0}
slot_data = {} # carrier_id -> armorSlots list
for item in items:
item_id = item.get("id")
name = item.get("name")
if not item_id or not name:
counts["skipped"] += 1
continue
props = item.get("properties") or {}
material = (props.get("material") or {}).get("name")
zones = ",".join(props.get("zones") or []) or None
result = upsert_item(
cursor, item_id, name, item.get("shortName"), "armor", item.get("weight"),
item.get("gridImageLink"), item.get("wikiLink"),
armor_class=props.get("class"),
durability=props.get("durability"),
material=material,
zones=zones,
)
counts[result] += 1
armor_slots = props.get("armorSlots") or []
if armor_slots:
slot_data[item_id] = armor_slots
conn.commit()
return counts, slot_data
def import_helmets(conn, items):
cursor = conn.cursor()
counts = {"inserted": 0, "updated": 0, "skipped": 0}
for item in items:
item_id = item.get("id")
name = item.get("name")
if not item_id or not name:
counts["skipped"] += 1
continue
props = item.get("properties") or {}
material = (props.get("material") or {}).get("name")
head_zones_list = props.get("headZones") or []
head_zones = ",".join(head_zones_list) or None
# True helmets cover the top of the head; face masks etc. go to 'headwear'
category = "helmet" if any("Top of the head" in z for z in head_zones_list) else "headwear"
result = upsert_item(
cursor, item_id, name, item.get("shortName"), category, item.get("weight"),
item.get("gridImageLink"), item.get("wikiLink"),
armor_class=props.get("class"),
durability=props.get("durability"),
material=material,
head_zones=head_zones,
deafening=props.get("deafening"),
)
counts[result] += 1
conn.commit()
return counts
def import_backpacks(conn, items):
cursor = conn.cursor()
counts = {"inserted": 0, "updated": 0, "skipped": 0}
for item in items:
item_id = item.get("id")
name = item.get("name")
if not item_id or not name:
counts["skipped"] += 1
continue
props = item.get("properties") or {}
capacity = props.get("capacity")
if capacity is None:
capacity = sum(
g.get("width", 0) * g.get("height", 0)
for g in (props.get("grids") or [])
) or None
result = upsert_item(
cursor, item_id, name, item.get("shortName"), "backpack", item.get("weight"),
item.get("gridImageLink"), item.get("wikiLink"),
capacity=capacity,
)
counts[result] += 1
conn.commit()
return counts
def import_rigs(conn, items):
cursor = conn.cursor()
counts = {"inserted": 0, "updated": 0, "skipped": 0}
slot_data = {} # carrier_id -> armorSlots list
for item in items:
item_id = item.get("id")
name = item.get("name")
if not item_id or not name:
counts["skipped"] += 1
continue
props = item.get("properties") or {}
zones = ",".join(props.get("zones") or []) or None
result = upsert_item(
cursor, item_id, name, item.get("shortName"), "rig", item.get("weight"),
item.get("gridImageLink"), item.get("wikiLink"),
armor_class=props.get("class"),
durability=props.get("durability"),
capacity=props.get("capacity"),
zones=zones,
)
counts[result] += 1
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()
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):
cursor = conn.cursor()
counts = {"inserted": 0, "updated": 0, "skipped": 0}
for item in items:
item_id = item.get("id")
name = item.get("name")
if not item_id or not name:
counts["skipped"] += 1
continue
result = upsert_item(
cursor, item_id, name, item.get("shortName"), "mod", item.get("weight"),
item.get("gridImageLink"), item.get("wikiLink"),
mod_type="suppressor",
)
counts[result] += 1
conn.commit()
return counts
def import_mods(conn, items):
"""Import generic weapon mods (scopes, grips, stocks, barrels, etc.)"""
cursor = conn.cursor()
counts = {"inserted": 0, "updated": 0, "skipped": 0}
for item in items:
item_id = item.get("id")
name = item.get("name")
if not item_id or not name:
counts["skipped"] += 1
continue
# Don't overwrite suppressors already imported with their mod_type
cursor.execute("SELECT id FROM gear_items WHERE api_id = ?", (item_id,))
existing = cursor.fetchone()
if existing:
# Only update weight/image if item already exists — don't overwrite mod_type
cursor.execute("""
UPDATE gear_items
SET name=?, short_name=?, weight_kg=?, grid_image_url=?, wiki_url=?,
updated_at=CURRENT_TIMESTAMP
WHERE api_id=?
""", (name, item.get("shortName"), item.get("weight"),
item.get("gridImageLink"), item.get("wikiLink"), item_id))
counts["updated"] += 1
else:
result = upsert_item(
cursor, item_id, name, item.get("shortName"), "mod", item.get("weight"),
item.get("gridImageLink"), item.get("wikiLink"),
)
counts[result] += 1
conn.commit()
return counts
def import_slots(conn, slot_data):
"""
Phase 2: insert gun_slots and gun_slot_items rows.
slot_data: { gun_id: [slot_dict, ...] }
Only inserts gun_slot_items rows where item_id exists in gear_items.
"""
cursor = conn.cursor()
slots_inserted = 0
slot_items_inserted = 0
slot_items_skipped = 0
# Build set of known item IDs for fast lookup
known_ids = {row[0] for row in cursor.execute("SELECT id FROM gear_items").fetchall()}
for gun_id, slots in slot_data.items():
for slot in slots:
slot_id = slot.get("id")
slot_name = slot.get("name") or ""
slot_nameid = slot.get("nameId") or ""
required = 1 if slot.get("required") else 0
if not slot_id:
continue
cursor.execute("""
INSERT OR REPLACE INTO gun_slots (gun_id, slot_id, slot_name, slot_nameid, required)
VALUES (?, ?, ?, ?, ?)
""", (gun_id, slot_id, slot_name, slot_nameid, required))
slots_inserted += 1
filters = slot.get("filters") or {}
allowed_items = filters.get("allowedItems") or []
for allowed in allowed_items:
item_id = allowed.get("id")
if not item_id or item_id not in known_ids:
slot_items_skipped += 1
continue
cursor.execute("""
INSERT OR IGNORE INTO gun_slot_items (gun_id, slot_id, item_id)
VALUES (?, ?, ?)
""", (gun_id, slot_id, item_id))
slot_items_inserted += 1
conn.commit()
return slots_inserted, slot_items_inserted, slot_items_skipped
def main():
print("=== OnlyScavs Gear Import ===")
# Phase 1a: fetch weapons (separate query — large payload)
print("\nFetching weapons from tarkov.dev...")
try:
weapon_data = gql(GRAPHQL_QUERY_WEAPONS, "weapons")
except Exception as e:
print(f"ERROR fetching weapons: {e}")
sys.exit(1)
weapons = weapon_data.get("weapons", [])
print(f" Fetched {len(weapons)} weapons")
# Phase 1b: fetch other gear
print("Fetching armor, helmets, backpacks, rigs, suppressors...")
try:
gear_data = gql(GRAPHQL_QUERY_GEAR, "gear")
except Exception as e:
print(f"ERROR fetching gear: {e}")
sys.exit(1)
armor = gear_data.get("armor", [])
helmets = gear_data.get("helmets", [])
backpacks = gear_data.get("backpacks", [])
rigs = gear_data.get("rigs", [])
suppressors = gear_data.get("suppressors", [])
mods = gear_data.get("mods", [])
plates = gear_data.get("plates", [])
# Wearables: only keep those whose properties resolved to ItemPropertiesHelmet
# (i.e. they have 'class' or 'headZones' set — not decorative items)
wearables_raw = gear_data.get("wearables", [])
# Known helmet IDs from the main helmet query — skip duplicates
helmet_ids = {h["id"] for h in helmets}
wearable_helmets = [
w for w in wearables_raw
if w["id"] not in helmet_ids
and isinstance(w.get("properties"), dict)
and (w["properties"].get("class") or w["properties"].get("headZones"))
]
print(f" armor={len(armor)}, helmets={len(helmets)}, wearable_helmets={len(wearable_helmets)}, "
f"backpacks={len(backpacks)}, rigs={len(rigs)}, suppressors={len(suppressors)}, "
f"mods={len(mods)}, plates={len(plates)})")
# Phase 2: insert into DB
conn = sqlite3.connect(DB_PATH)
conn.execute("PRAGMA foreign_keys = ON")
try:
print("\nImporting weapons...")
wcounts, slot_data = import_weapons(conn, weapons)
print(f" guns: +{wcounts['inserted']} inserted, ~{wcounts['updated']} updated, -{wcounts['skipped']} skipped")
print("Importing plates (armorPlate items)...")
c = import_plates(conn, plates)
print(f" plates: +{c['inserted']} inserted, ~{c['updated']} updated, -{c['skipped']} skipped")
print("Importing armor...")
c, armor_slot_data = import_armor(conn, armor)
print(f" armor: +{c['inserted']} inserted, ~{c['updated']} updated, -{c['skipped']} skipped")
print("Importing helmets...")
c = import_helmets(conn, helmets)
print(f" helmets: +{c['inserted']} inserted, ~{c['updated']} updated, -{c['skipped']} skipped")
print("Importing wearable helmets (missing from helmet type)...")
c = import_helmets(conn, wearable_helmets)
print(f" wearable helmets: +{c['inserted']} inserted, ~{c['updated']} updated, -{c['skipped']} skipped")
print("Importing backpacks...")
c = import_backpacks(conn, backpacks)
print(f" backpacks: +{c['inserted']} inserted, ~{c['updated']} updated, -{c['skipped']} skipped")
print("Importing rigs...")
c, rig_slot_data = import_rigs(conn, rigs)
print(f" rigs: +{c['inserted']} inserted, ~{c['updated']} updated, -{c['skipped']} skipped")
print("Importing suppressors...")
c = import_suppressors(conn, suppressors)
print(f" suppressors: +{c['inserted']} inserted, ~{c['updated']} updated, -{c['skipped']} skipped")
print("Importing mods (scopes, grips, stocks, barrels, etc.)...")
c = import_mods(conn, mods)
print(f" mods: +{c['inserted']} inserted, ~{c['updated']} updated, -{c['skipped']} skipped")
# Phase 3: gun slots (needs all items in DB first)
print("Importing gun slots and compatibility data...")
slots_ins, slot_items_ins, slot_items_skip = import_slots(conn, slot_data)
print(f" gun_slots: {slots_ins} rows")
print(f" gun_slot_items: {slot_items_ins} rows inserted, {slot_items_skip} skipped (item not in DB)")
# Phase 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
print("Classifying mod types from slot data...")
cursor = conn.cursor()
updated_mod_types = 0
for slot_nameid, mod_type in SLOT_TYPE_MAP.items():
result = cursor.execute("""
UPDATE gear_items
SET mod_type = ?
WHERE id IN (
SELECT gsi.item_id
FROM gun_slot_items gsi
JOIN gun_slots gs ON gs.gun_id = gsi.gun_id AND gs.slot_id = gsi.slot_id
WHERE gs.slot_nameid = ?
)
AND category = 'mod'
AND (mod_type IS NULL OR mod_type != 'suppressor')
""", (mod_type, slot_nameid))
updated_mod_types += result.rowcount
conn.commit()
print(f" mod_type set on {updated_mod_types} mod items")
except Exception as e:
conn.rollback()
print(f"ERROR: Database operation failed — {e}")
import traceback
traceback.print_exc()
sys.exit(1)
finally:
conn.close()
print("\n=== Import complete ===")
print("Run: SELECT category, COUNT(*) FROM gear_items GROUP BY category;")
print(" to verify row counts.")
if __name__ == "__main__":
main()

View File

@@ -62,9 +62,7 @@ def upsert_keys(conn, keys):
continue continue
cursor.execute( cursor.execute(
""" "SELECT id FROM keys WHERE api_id = ?",
SELECT id FROM keys WHERE api_id = ?
""",
(api_id,) (api_id,)
) )
row = cursor.fetchone() row = cursor.fetchone()
@@ -73,19 +71,20 @@ def upsert_keys(conn, keys):
cursor.execute( cursor.execute(
""" """
UPDATE keys UPDATE keys
SET name = ?, short_name = ?, weight_kg = ?, uses = ?, wiki_url = ?, grid_image_url = ? SET id = COALESCE(id, ?),
name = ?, short_name = ?, weight_kg = ?, uses = ?, wiki_url = ?, grid_image_url = ?
WHERE api_id = ? WHERE api_id = ?
""", """,
(name, short_name, weight, uses, wiki_url, grid_image_url, api_id) (api_id, name, short_name, weight, uses, wiki_url, grid_image_url, api_id)
) )
updated += 1 updated += 1
else: else:
cursor.execute( cursor.execute(
""" """
INSERT INTO keys (api_id, name, short_name, weight_kg, uses) INSERT INTO keys (id, api_id, name, short_name, weight_kg, uses, wiki_url, grid_image_url)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", """,
(api_id, name, short_name, weight, uses, icon_url, wiki_url) (api_id, api_id, name, short_name, weight, uses, wiki_url, grid_image_url)
) )
inserted += 1 inserted += 1

141
import_quests.py Normal file
View File

@@ -0,0 +1,141 @@
import requests
import sqlite3
DB_PATH = "tarkov.db"
API_URL = "https://api.tarkov.dev/graphql"
QUERY = """
{
tasks {
id
name
wikiLink
trader {
name
}
taskRequirements {
task {
id
}
}
}
}
"""
def fetch_quests():
print("Fetching quests from Tarkov.dev...")
response = requests.post(API_URL, json={"query": QUERY})
response.raise_for_status()
result = response.json()
if "errors" in result:
print("GraphQL Errors:")
print(result["errors"])
raise Exception("GraphQL query failed.")
return result["data"]["tasks"]
def setup_db(conn):
cur = conn.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS quests (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
trader TEXT NOT NULL,
wiki_link TEXT
)
""")
cur.execute("""
CREATE TABLE IF NOT EXISTS quest_deps (
quest_id TEXT NOT NULL,
depends_on TEXT NOT NULL,
PRIMARY KEY (quest_id, depends_on)
)
""")
conn.commit()
def store_quests(conn, quests):
cur = conn.cursor()
for q in quests:
cur.execute(
"INSERT OR REPLACE INTO quests VALUES (?, ?, ?, ?)",
(q["id"], q["name"], q["trader"]["name"], q.get("wikiLink"))
)
for req in q.get("taskRequirements") or []:
cur.execute(
"INSERT OR IGNORE INTO quest_deps VALUES (?, ?)",
(q["id"], req["task"]["id"])
)
conn.commit()
def get_collector_id(conn):
cur = conn.cursor()
cur.execute("SELECT id FROM quests WHERE name = 'Collector'")
row = cur.fetchone()
if not row:
raise Exception("Collector quest not found in DB.")
return row[0]
def get_all_prereqs(conn, quest_id, seen=None):
if seen is None:
seen = set()
cur = conn.cursor()
cur.execute(
"SELECT depends_on FROM quest_deps WHERE quest_id = ?",
(quest_id,)
)
deps = [r[0] for r in cur.fetchall()]
for dep in deps:
if dep not in seen:
seen.add(dep)
get_all_prereqs(conn, dep, seen)
return seen
def main():
quests = fetch_quests()
conn = sqlite3.connect(DB_PATH)
setup_db(conn)
store_quests(conn, quests)
collector_id = get_collector_id(conn)
prereqs = get_all_prereqs(conn, collector_id)
print("\n=== Quests Required for Collector ===\n")
if prereqs:
cur = conn.cursor()
placeholders = ",".join("?" * len(prereqs))
cur.execute(
f"""
SELECT name, trader, wiki_link
FROM quests
WHERE id IN ({placeholders})
ORDER BY trader, name
""",
list(prereqs)
)
rows = cur.fetchall()
for name, trader, _ in rows:
print(f"[ ] {trader}: {name}")
print(f"\nTotal required quests: {len(rows)}")
else:
print("Collector has no prerequisites (unexpected).")
conn.close()
if __name__ == "__main__":
main()

21
migration_add_plates.sql Normal file
View 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);

94
migrations_v2.sql Normal file
View File

@@ -0,0 +1,94 @@
PRAGMA foreign_keys = ON;
-- Unified gear item table for weapons, armor, helmets, backpacks, rigs, and mods/attachments
CREATE TABLE IF NOT EXISTS gear_items (
id TEXT PRIMARY KEY,
api_id TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
short_name TEXT,
category TEXT NOT NULL, -- 'gun' | 'armor' | 'helmet' | 'backpack' | 'rig' | 'mod'
weight_kg REAL,
grid_image_url TEXT,
wiki_url TEXT,
-- Weapon-specific fields
caliber TEXT,
fire_rate INTEGER,
ergonomics INTEGER,
recoil_vertical INTEGER,
default_weight REAL, -- weight of gun with default preset mods attached
-- Armor / Helmet / Rig shared fields
armor_class INTEGER, -- 1-6, NULL if not applicable
durability REAL,
material TEXT,
zones TEXT, -- comma-separated protection zone names
head_zones TEXT, -- comma-separated head zone names (helmets only)
deafening TEXT, -- None | Low | Medium | High | Complete (helmets)
-- Backpack / Rig capacity
capacity INTEGER, -- total grid cell count
-- Mod/attachment classification
mod_type TEXT, -- 'suppressor' | 'scope' | 'flashlight' | 'foregrip' | 'stock' | etc.
imported_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_gear_category ON gear_items(category);
CREATE INDEX IF NOT EXISTS idx_gear_armor_class ON gear_items(armor_class);
CREATE INDEX IF NOT EXISTS idx_gear_capacity ON gear_items(capacity);
CREATE INDEX IF NOT EXISTS idx_gear_weight ON gear_items(weight_kg);
CREATE INDEX IF NOT EXISTS idx_gear_caliber ON gear_items(caliber);
-- Weapon mod slots: records which named slots exist on each gun
CREATE TABLE IF NOT EXISTS gun_slots (
gun_id TEXT NOT NULL REFERENCES gear_items(id) ON DELETE CASCADE,
slot_id TEXT NOT NULL, -- tarkov internal slot ID
slot_name TEXT NOT NULL, -- human-readable name (e.g. "Muzzle")
slot_nameid TEXT, -- normalized nameId (e.g. "mod_muzzle")
required INTEGER DEFAULT 0, -- 1 if the slot must be filled
PRIMARY KEY (gun_id, slot_id)
);
CREATE INDEX IF NOT EXISTS idx_gun_slots_nameid ON gun_slots(slot_nameid);
-- Which items are compatible with each gun slot (from API filters.allowedItems)
CREATE TABLE IF NOT EXISTS gun_slot_items (
gun_id TEXT NOT NULL,
slot_id TEXT NOT NULL,
item_id TEXT NOT NULL REFERENCES gear_items(id) ON DELETE CASCADE,
PRIMARY KEY (gun_id, slot_id, item_id),
FOREIGN KEY (gun_id, slot_id) REFERENCES gun_slots(gun_id, slot_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_gun_slot_items_item ON gun_slot_items(item_id);
-- 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
CREATE TABLE IF NOT EXISTS saved_builds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL DEFAULT 'My Build',
gun_id TEXT REFERENCES gear_items(id),
armor_id TEXT REFERENCES gear_items(id),
helmet_id TEXT REFERENCES gear_items(id),
rig_id TEXT REFERENCES gear_items(id),
backpack_id TEXT REFERENCES gear_items(id),
notes TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);

BIN
tarkov.db

Binary file not shown.

408
templates/barters.html Normal file
View File

@@ -0,0 +1,408 @@
<!doctype html>
<html>
<head>
<title>OnlyScavs Barter Calculator</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
:root {
--bg: #121212;
--panel: #1a1a1a;
--panel2: #1e1e1e;
--text: #eee;
--muted: #888;
--border: #2a2a2a;
--accent: #9ccfff;
--accent2: #ffd580;
--good: #6ec96e;
--bad: #e06060;
--warn: #e0a040;
}
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
margin: 0;
padding-top: 52px;
background-image: url('/assets/onlyscavs.png');
background-attachment: fixed;
background-size: cover;
background-position: center 65%;
}
body::before {
content: '';
position: fixed;
inset: 0;
background: rgba(14,14,14,0.88);
pointer-events: none;
z-index: 0;
}
.page { max-width: 1100px; margin: 0 auto; padding: 24px 16px; position: relative; z-index: 1; }
.site-nav {
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
display: flex; align-items: center; justify-content: space-between;
padding: 0 24px; height: 52px;
background: rgba(14,14,14,0.92);
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(255,255,255,0.06);
}
.nav-brand { font-size: 0.85rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: var(--accent); text-decoration: none; flex-shrink: 0; }
.nav-links { display: flex; gap: 2px; flex-wrap: wrap; }
.nav-links a { color: #666; text-decoration: none; font-size: 0.8rem; padding: 5px 10px; border-radius: 5px; transition: color 0.15s, background 0.15s; }
.nav-links a:hover { color: var(--text); background: rgba(255,255,255,0.06); }
.nav-links a.active { color: var(--accent); background: rgba(156,207,255,0.08); }
h1 { font-size: 1.4rem; margin: 0 0 4px; }
.subtitle { color: var(--muted); font-size: 0.88rem; margin: 0 0 20px; }
/* ── Filters ── */
.filters {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
margin-bottom: 20px;
}
.filters input[type=text] {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
padding: 6px 10px;
font-size: 0.88rem;
width: 220px;
}
.filters input[type=text]:focus { outline: none; border-color: var(--accent); }
.filters select {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
padding: 6px 10px;
font-size: 0.88rem;
}
.filters label { color: var(--muted); font-size: 0.82rem; }
/* ── Barter Cards ── */
.barter-list { display: flex; flex-direction: column; gap: 12px; }
.barter-card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px 18px;
}
.barter-card.task-locked {
border-color: #3a3020;
}
.barter-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.reward-icon {
width: 40px;
height: 40px;
object-fit: contain;
background: #111;
border-radius: 4px;
border: 1px solid var(--border);
flex-shrink: 0;
}
.reward-info { flex: 1; min-width: 0; }
.reward-name {
font-size: 1rem;
font-weight: 700;
color: var(--accent);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.reward-name a { color: inherit; text-decoration: none; }
.reward-name a:hover { text-decoration: underline; }
.reward-meta {
font-size: 0.78rem;
color: var(--muted);
margin-top: 2px;
}
.trader-badge {
font-size: 0.75rem;
font-weight: 700;
padding: 2px 8px;
border-radius: 99px;
background: #1e2a1e;
color: var(--good);
border: 1px solid #2a3e2a;
white-space: nowrap;
}
.task-badge {
font-size: 0.72rem;
padding: 2px 8px;
border-radius: 99px;
background: #2a2010;
color: var(--warn);
border: 1px solid #3a3020;
white-space: nowrap;
}
/* ── Required items ── */
.required-label {
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 8px;
}
.required-items {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 14px;
}
.req-item {
display: flex;
align-items: center;
gap: 6px;
background: var(--panel2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 10px;
min-width: 160px;
}
.req-icon {
width: 30px;
height: 30px;
object-fit: contain;
background: #111;
border-radius: 3px;
flex-shrink: 0;
}
.req-icon-placeholder {
width: 30px;
height: 30px;
background: #222;
border-radius: 3px;
flex-shrink: 0;
}
.req-text { flex: 1; min-width: 0; }
.req-name {
font-size: 0.82rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.req-count { font-size: 0.75rem; color: var(--muted); }
.req-price-wrap {
display: flex;
align-items: center;
gap: 4px;
}
.req-price-input {
width: 90px;
background: #111;
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text);
font-size: 0.82rem;
padding: 3px 6px;
text-align: right;
}
.req-price-input:focus { outline: none; border-color: var(--accent); }
.req-price-unit { font-size: 0.72rem; color: var(--muted); }
/* ── Total cost row ── */
.total-row {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
padding-top: 10px;
border-top: 1px solid var(--border);
}
.total-label { font-size: 0.82rem; color: var(--muted); }
.total-cost {
font-size: 1.1rem;
font-weight: 700;
color: var(--accent2);
font-variant-numeric: tabular-nums;
}
.total-hint { font-size: 0.75rem; color: var(--muted); }
/* ── Empty state ── */
.empty { color: var(--muted); text-align: center; padding: 48px 0; font-size: 0.9rem; }
</style>
</head>
<body>
<div class="page">
<nav class="site-nav">
<a class="nav-brand" href="/">OnlyScavs</a>
<div class="nav-links">
<a href="/keys">Keys</a>
<a href="/collector">Collector</a>
<a href="/quests">Quests</a>
<a href="/loadout">Loadout</a>
<a href="/meds">Injectors</a>
<a href="/barters" class="active">Barters</a>
</div>
</nav>
<h1>Barter Calculator</h1>
<p class="subtitle">Enter flea market prices for required items to see the total rouble cost of any barter.</p>
<div class="filters">
<div>
<label>Search</label><br>
<input type="text" id="search" placeholder="item name, trader…" oninput="applyFilters()">
</div>
<div>
<label>Trader</label><br>
<select id="traderFilter" onchange="applyFilters()">
<option value="">All traders</option>
{% set traders = barters | map(attribute='trader') | unique | sort %}
{% for t in traders %}
<option value="{{ t }}">{{ t }}</option>
{% endfor %}
</select>
</div>
<div>
<label>LL (min)</label><br>
<select id="llFilter" onchange="applyFilters()">
<option value="0">Any</option>
<option value="1">LL1+</option>
<option value="2">LL2+</option>
<option value="3">LL3+</option>
<option value="4">LL4</option>
</select>
</div>
<div>
<label>Task locked</label><br>
<select id="taskFilter" onchange="applyFilters()">
<option value="">All</option>
<option value="no">No task required</option>
<option value="yes">Task required</option>
</select>
</div>
</div>
{% if barters %}
<div class="barter-list" id="barterList">
{% for b in barters %}
<div class="barter-card{% if b.task_unlock %} task-locked{% endif %}"
data-trader="{{ b.trader }}"
data-level="{{ b.level }}"
data-task="{{ 'yes' if b.task_unlock else 'no' }}"
data-search="{{ (b.reward_name ~ ' ' ~ b.trader ~ ' ' ~ (b.required | map(attribute='name') | join(' '))) | lower }}">
<div class="barter-header">
{% if b.reward_icon %}
<img class="reward-icon" src="{{ b.reward_icon }}" alt="{{ b.reward_short }}" loading="lazy">
{% endif %}
<div class="reward-info">
<div class="reward-name">
{% if b.reward_wiki %}
<a href="{{ b.reward_wiki }}" target="_blank" rel="noopener">{{ b.reward_name }}{% if b.reward_count > 1 %} ×{{ b.reward_count }}{% endif %}</a>
{% else %}
{{ b.reward_name }}{% if b.reward_count > 1 %} ×{{ b.reward_count }}{% endif %}
{% endif %}
</div>
<div class="reward-meta">{{ b.trader }} · LL{{ b.level }}</div>
</div>
<span class="trader-badge">{{ b.trader }} LL{{ b.level }}</span>
{% if b.task_unlock %}
<span class="task-badge" title="Requires task: {{ b.task_unlock }}">🔒 {{ b.task_unlock }}</span>
{% endif %}
</div>
<div class="required-label">Required items</div>
<div class="required-items">
{% for ri in b.required %}
<div class="req-item">
{% if ri.icon %}
<img class="req-icon" src="{{ ri.icon }}" alt="{{ ri.short }}" loading="lazy">
{% else %}
<div class="req-icon-placeholder"></div>
{% endif %}
<div class="req-text">
<div class="req-name" title="{{ ri.name }}">{{ ri.name }}</div>
<div class="req-count">× {{ ri.count }}</div>
</div>
<div class="req-price-wrap">
<input class="req-price-input"
type="number"
min="0"
step="1"
placeholder="price"
title="Price per unit in roubles"
data-count="{{ ri.count }}"
oninput="recalc(this)">
<span class="req-price-unit"></span>
</div>
</div>
{% endfor %}
</div>
<div class="total-row">
<span class="total-label">Total cost:</span>
<span class="total-cost" data-total="0"></span>
<span class="total-hint">Enter prices above to calculate</span>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty">No barter data available. Check your connection to tarkov.dev.</div>
{% endif %}
</div>
<script>
// Recalculate total cost for a barter card when any price input changes
function recalc(input) {
const card = input.closest('.barter-card');
const inputs = card.querySelectorAll('.req-price-input');
let total = 0;
let allFilled = true;
inputs.forEach(inp => {
const price = parseFloat(inp.value) || 0;
const count = parseInt(inp.dataset.count, 10) || 1;
if (inp.value.trim() === '') allFilled = false;
total += price * count;
});
const totalEl = card.querySelector('.total-cost');
const hintEl = card.querySelector('.total-hint');
if (total > 0) {
totalEl.textContent = total.toLocaleString() + ' ₽';
hintEl.textContent = allFilled ? 'all items priced' : 'some items unpriced';
} else {
totalEl.textContent = '—';
hintEl.textContent = 'Enter prices above to calculate';
}
}
// Filter cards by search text, trader, level, and task lock
function applyFilters() {
const search = document.getElementById('search').value.toLowerCase();
const trader = document.getElementById('traderFilter').value;
const ll = parseInt(document.getElementById('llFilter').value, 10) || 0;
const task = document.getElementById('taskFilter').value;
document.querySelectorAll('.barter-card').forEach(card => {
const matchSearch = !search || card.dataset.search.includes(search);
const matchTrader = !trader || card.dataset.trader === trader;
const matchLL = !ll || parseInt(card.dataset.level, 10) >= ll;
const matchTask = !task || card.dataset.task === task;
card.style.display = (matchSearch && matchTrader && matchLL && matchTask) ? '' : 'none';
});
}
</script>
</body>
</html>

537
templates/collector.html Normal file
View File

@@ -0,0 +1,537 @@
{# ══════════════════════════════════════════════
MACROS — must come before first use in Jinja2
══════════════════════════════════════════════ #}
{# List view: indented tree with ├── / └── connector lines.
open_stack: list of booleans — True = ancestor has more siblings (draw vert), False = last (draw blank).
is_last: whether this node is the last sibling among its parent's children. #}
{% macro render_list_item(qid, quest_by_id, children, visible, collector_prereqs, open_stack, is_last, collector_id) %}
{% set q = quest_by_id[qid] %}
{% set visible_kids = [] %}
{% for cid in children.get(qid, []) %}
{% if cid in visible %}{% set _ = visible_kids.append(cid) %}{% endif %}
{% endfor %}
<div class="list-item">
<div class="list-indent">
{% for open in open_stack %}
<div class="list-indent-seg {{ 'vert' if open else 'blank' }}"></div>
{% endfor %}
{% if open_stack %}
<div class="list-indent-seg {{ 'elbow' if is_last else 'tee' }}"></div>
{% endif %}
</div>
<div class="list-row{% if q.done %} done{% endif %}"
data-id="{{ qid }}" data-done="{{ '1' if q.done else '0' }}"
data-counted="{{ '1' if qid in collector_prereqs else '0' }}">
{% if qid in collector_prereqs %}<span class="kappa-star"></span>{% endif %}
<span class="list-name">{{ q.name }}</span>
{% if q.wiki_link %}<a class="list-wiki" href="{{ q.wiki_link }}" target="_blank">wiki</a>{% endif %}
{% if qid != collector_id %}<button class="toggle-btn" onclick="toggle(this)">{{ '✓' if q.done else '○' }}</button>{% endif %}
</div>
</div>
{% set child_stack = open_stack + [not is_last] %}
{% for cid in visible_kids %}
{% set child = quest_by_id[cid] %}
{% set child_last = loop.last %}
{% if child.trader != q.trader %}
<div class="list-item">
<div class="list-indent">
{% for open in child_stack %}
<div class="list-indent-seg {{ 'vert' if open else 'blank' }}"></div>
{% endfor %}
<div class="list-indent-seg {{ 'elbow' if child_last else 'tee' }}"></div>
</div>
<div class="list-row{% if child.done %} done{% endif %}"
data-id="{{ cid }}" data-done="{{ '1' if child.done else '0' }}"
data-counted="{{ '1' if cid in collector_prereqs else '0' }}">
{% if cid in collector_prereqs %}<span class="kappa-star"></span>{% endif %}
<span class="list-name">{{ child.name }}</span>
{% if child.wiki_link %}<a class="list-wiki" href="{{ child.wiki_link }}" target="_blank">wiki</a>{% endif %}
<span class="cross-badge">{{ child.trader }}</span>
{% if cid != collector_id %}<button class="toggle-btn" onclick="toggle(this)">{{ '✓' if child.done else '○' }}</button>{% endif %}
</div>
</div>
{% else %}
{{ render_list_item(cid, quest_by_id, children, visible, collector_prereqs, child_stack, child_last, collector_id) }}
{% endif %}
{% endfor %}
{% endmacro %}
<!doctype html>
<html>
<head>
<title>OnlyScavs Collector Checklist</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
:root {
--bg: #121212;
--panel: #1a1a1a;
--text: #eee;
--muted: #888;
--border: #2a2a2a;
--accent: #9ccfff;
--done-text: #6ec96e;
--done-bg: #1a2a1a;
--kappa: #f0c040;
--line: #333;
}
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
margin: 0;
padding-top: 52px;
background-image: url('/assets/onlyscavs.png');
background-attachment: fixed;
background-size: cover;
background-position: center 65%;
}
body::before {
content: '';
position: fixed;
inset: 0;
background: rgba(14,14,14,0.88);
pointer-events: none;
z-index: 0;
}
.page { max-width: 960px; margin: 0 auto; padding: 24px 16px; position: relative; z-index: 1; }
.site-nav {
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
display: flex; align-items: center; justify-content: space-between;
padding: 0 24px; height: 52px;
background: rgba(14,14,14,0.92);
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(255,255,255,0.06);
}
.nav-brand { font-size: 0.85rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: var(--accent); text-decoration: none; flex-shrink: 0; }
.nav-links { display: flex; gap: 2px; flex-wrap: wrap; }
.nav-links a { color: #666; text-decoration: none; font-size: 0.8rem; padding: 5px 10px; border-radius: 5px; transition: color 0.15s, background 0.15s; }
.nav-links a:hover { color: var(--text); background: rgba(255,255,255,0.06); }
.nav-links a.active { color: var(--accent); background: rgba(156,207,255,0.08); }
h1 { margin: 0 0 4px; }
.toolbar {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.filter-btn {
background: var(--panel);
border: 1px solid #444;
color: var(--text);
border-radius: 6px;
padding: 5px 14px;
cursor: pointer;
font-size: 0.85rem;
text-decoration: none;
}
.filter-btn.active { border-color: var(--kappa); color: var(--kappa); }
.sep { color: var(--border); }
.subtitle {
color: var(--muted);
margin: 0 0 16px;
font-size: 0.95rem;
}
.progress-bar-wrap {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 999px;
height: 10px;
margin-bottom: 20px;
overflow: hidden;
}
.progress-bar-fill {
background: var(--done-text);
height: 100%;
border-radius: 999px;
transition: width 0.3s;
}
.legend {
display: flex;
gap: 16px;
font-size: 0.8rem;
color: var(--muted);
flex-wrap: wrap;
margin-bottom: 16px;
}
.legend span { display: flex; align-items: center; gap: 5px; }
/* Trader section */
.trader-section { margin-bottom: 8px; }
.trader-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 8px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 6px;
cursor: pointer;
user-select: none;
}
.trader-header:hover { border-color: #444; }
.trader-name {
font-weight: bold;
font-size: 0.95rem;
flex: 1;
}
.trader-counts { font-size: 0.8rem; color: var(--muted); }
.chevron { color: var(--muted); font-size: 0.8rem; transition: transform 0.15s; }
.trader-section.collapsed .chevron { transform: rotate(-90deg); }
.trader-body { padding: 6px 0 6px 8px; }
.trader-section.collapsed .trader-body { display: none; }
/* Flow tree */
.tree-root {
margin: 6px 0;
display: flex;
flex-direction: column;
align-items: center;
position: relative;
}
.tree-children {
display: flex;
gap: 14px;
align-items: flex-start;
justify-content: center;
position: relative;
padding-top: 18px;
flex-wrap: wrap;
}
.tree-children:before {
content: "";
position: absolute;
top: 8px;
left: 8px;
right: 8px;
height: 1px;
background: var(--border);
opacity: 0.9;
}
.tree-children > .tree-root {
padding-top: 8px;
}
.tree-children > .tree-root:before {
content: "";
position: absolute;
top: 0;
left: 50%;
width: 1px;
height: 8px;
background: var(--border);
}
.quest-node {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 6px;
border-radius: 4px;
margin: 2px 0;
position: relative;
background: #141820;
border: 1px solid #1b2230;
}
.quest-node.has-children:after {
content: "";
position: absolute;
left: 50%;
bottom: -8px;
width: 1px;
height: 8px;
background: var(--border);
}
.quest-node:hover { background: #1e1e1e; }
.quest-node.done .quest-label { text-decoration: line-through; color: var(--done-text); }
.quest-node.done { background: var(--done-bg); }
.quest-label { flex: 1; font-size: 0.9rem; }
.quest-label a { color: var(--accent); font-size: 0.75rem; margin-left: 6px; }
.kappa-star { color: var(--kappa); font-size: 0.75rem; flex-shrink: 0; }
.cross-trader {
font-size: 0.75rem;
color: var(--muted);
font-style: italic;
flex-shrink: 0;
}
.toggle-btn {
background: transparent;
border: 1px solid #444;
color: var(--muted);
border-radius: 4px;
padding: 2px 7px;
cursor: pointer;
font-size: 0.75rem;
flex-shrink: 0;
}
.quest-node.done .toggle-btn {
border-color: #3a6a3a;
color: var(--done-text);
}
/* ── LIST VIEW ──
Classic file-manager tree: ├── and └── connectors. */
.list-view .trader-body { padding: 4px 0; }
.list-tree { padding: 0; margin: 0; }
.list-item { display: flex; align-items: flex-start; }
.list-indent { display: flex; flex-shrink: 0; }
.list-indent-seg { width: 20px; flex-shrink: 0; position: relative; min-height: 30px; }
.list-indent-seg.vert::before {
content: ""; position: absolute; top: 0; bottom: 0; left: 9px; width: 1px; background: var(--line);
}
.list-indent-seg.tee::before {
content: ""; position: absolute; top: 0; bottom: 0; left: 9px; width: 1px; background: var(--line);
}
.list-indent-seg.tee::after {
content: ""; position: absolute; top: 50%; left: 9px; width: 11px; height: 1px; background: var(--line);
}
.list-indent-seg.elbow::before {
content: ""; position: absolute; top: 0; bottom: 50%; left: 9px; width: 1px; background: var(--line);
}
.list-indent-seg.elbow::after {
content: ""; position: absolute; top: 50%; left: 9px; width: 11px; height: 1px; background: var(--line);
}
.list-indent-seg.blank {}
.list-row {
flex: 1; display: flex; align-items: center; gap: 6px;
padding: 4px 6px 4px 2px; border-radius: 4px; margin: 1px 0;
background: transparent; min-height: 30px;
}
.list-row:hover { background: #1a1a1a; }
.list-row.done .list-name { text-decoration: line-through; color: var(--done-text); }
.list-name { font-size: 0.85rem; flex: 1; }
.list-wiki { color: var(--accent); font-size: 0.72rem; text-decoration: none; flex-shrink: 0; }
.list-wiki:hover { text-decoration: underline; }
.list-row .kappa-star { font-size: 0.72rem; }
.list-row .cross-badge { font-size: 0.7rem; }
.list-row .toggle-btn { font-size: 0.72rem; }
.list-row.done .toggle-btn { border-color: #3a6a3a; color: var(--done-text); }
.flow-view.hidden, .list-view.hidden { display: none; }
</style>
</head>
<body>
<div class="page">
<nav class="site-nav">
<a class="nav-brand" href="/">OnlyScavs</a>
<div class="nav-links">
<a href="/keys">Keys</a>
<a href="/collector" class="active">Collector</a>
<a href="/quests">Quests</a>
<a href="/loadout">Loadout</a>
<a href="/meds">Injectors</a>
<a href="/barters">Barters</a>
</div>
</nav>
<h1>Collector Checklist</h1>
<p class="subtitle">
{{ done }} / {{ total }} quests completed
</p>
<div class="progress-bar-wrap">
<div class="progress-bar-fill" style="width: {{ (done / total * 100) | round(1) if total else 0 }}%"></div>
</div>
<div class="toolbar">
<a class="filter-btn {% if view != 'list' %}active{% endif %}" href="/collector?view=flow">Flow</a>
<a class="filter-btn {% if view == 'list' %}active{% endif %}" href="/collector?view=list">List</a>
<span class="sep">|</span>
<button class="filter-btn" type="button" onclick="setAllTradersCollapsed(true)">Collapse all</button>
<button class="filter-btn" type="button" onclick="setAllTradersCollapsed(false)">Expand all</button>
</div>
<div class="legend">
<span><span style="color:var(--kappa)"></span> Required for Collector</span>
<span><span style="color:var(--done-text)"></span> Marked done</span>
<span><span style="color:var(--muted);font-style:italic">← Trader</span> Cross-trader dependency</span>
</div>
{% macro render_node(qid, quest_by_id, children, visible, collector_prereqs, collector_id, is_root=False) %}
{% set q = quest_by_id[qid] %}
{% set visible_kids = [] %}
{% for cid in children.get(qid, []) %}
{% if cid in visible %}{% set _ = visible_kids.append(cid) %}{% endif %}
{% endfor %}
<div class="tree-root">
<div class="quest-node {% if q.done %}done{% endif %} {% if is_root %}root-node{% endif %}{% if visible_kids %} has-children{% endif %}"
id="qnode-{{ qid }}"
data-id="{{ qid }}"
data-done="{{ '1' if q.done else '0' }}"
data-counted="{{ '1' if qid in collector_prereqs else '0' }}">
{% if qid in collector_prereqs %}<span class="kappa-star" title="Required for Collector"></span>{% endif %}
<span class="quest-label">
{{ q.name }}
{% if q.wiki_link %}<a href="{{ q.wiki_link }}" target="_blank">wiki</a>{% endif %}
</span>
{% if qid != collector_id %}
<button class="toggle-btn" onclick="toggle(this)">
{{ '✓' if q.done else '○' }}
</button>
{% endif %}
</div>
{% if visible_kids %}
<div class="tree-children">
{% for cid in visible_kids %}
{% set child = quest_by_id[cid] %}
{% if child.trader != q.trader %}
<div class="quest-node {% if child.done %}done{% endif %}"
data-id="{{ cid }}"
data-done="{{ '1' if child.done else '0' }}"
data-counted="{{ '1' if cid in collector_prereqs else '0' }}">
{% if cid in collector_prereqs %}<span class="kappa-star"></span>{% endif %}
<span class="quest-label">
{{ child.name }}
{% if child.wiki_link %}<a href="{{ child.wiki_link }}" target="_blank">wiki</a>{% endif %}
</span>
<span class="cross-trader">← {{ child.trader }}</span>
{% if cid != collector_id %}
<button class="toggle-btn" onclick="toggle(this)">{{ '✓' if child.done else '○' }}</button>
{% endif %}
</div>
{% else %}
{{ render_node(cid, quest_by_id, children, visible, collector_prereqs, collector_id, false) }}
{% endif %}
{% endfor %}
</div>
{% endif %}
</div>
{% endmacro %}
{# ── FLOW VIEW ── #}
<div class="flow-view {% if view == 'list' %}hidden{% endif %}">
{% for trader in traders %}
{% set roots = trader_roots[trader] %}
{% set total_trader = namespace(n=0) %}
{% set done_trader = namespace(n=0) %}
{% for qid in visible %}
{% if quest_by_id[qid].trader == trader and qid in collector_prereqs %}
{% set total_trader.n = total_trader.n + 1 %}
{% if quest_by_id[qid].done %}{% set done_trader.n = done_trader.n + 1 %}{% endif %}
{% endif %}
{% endfor %}
<div class="trader-section" id="flow-{{ trader | replace(' ', '-') }}">
<div class="trader-header" onclick="toggleTrader(this)">
<span class="trader-name">{{ trader }}</span>
<span class="trader-counts">{{ done_trader.n }} / {{ total_trader.n }}</span>
<span class="chevron"></span>
</div>
<div class="trader-body">
{% for root_id in roots %}
{{ render_node(root_id, quest_by_id, children, visible, collector_prereqs, collector_id, true) }}
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{# ── LIST VIEW ── #}
<div class="list-view {% if view != 'list' %}hidden{% endif %}">
{% for trader in traders %}
{% set roots = trader_roots[trader] %}
{% set total_trader = namespace(n=0) %}
{% set done_trader = namespace(n=0) %}
{% for qid in visible %}
{% if quest_by_id[qid].trader == trader and qid in collector_prereqs %}
{% set total_trader.n = total_trader.n + 1 %}
{% if quest_by_id[qid].done %}{% set done_trader.n = done_trader.n + 1 %}{% endif %}
{% endif %}
{% endfor %}
<div class="trader-section" id="list-{{ trader | replace(' ', '-') }}">
<div class="trader-header" onclick="toggleTrader(this)">
<span class="trader-name">{{ trader }}</span>
<span class="trader-counts">{{ done_trader.n }} / {{ total_trader.n }}</span>
<span class="chevron"></span>
</div>
<div class="trader-body">
<div class="list-tree">
{% for root_id in roots %}
{{ render_list_item(root_id, quest_by_id, children, visible, collector_prereqs, [], loop.last, collector_id) }}
{% endfor %}
</div>
</div>
</div>
{% endfor %}
</div>
</div>
<script>
let doneCount = {{ done }};
const total = {{ total }};
function updateProgress() {
document.querySelector('.subtitle').textContent = doneCount + ' / ' + total + ' quests completed';
const pct = total ? (doneCount / total * 100).toFixed(1) : 0;
document.querySelector('.progress-bar-fill').style.width = pct + '%';
}
function toggleTrader(header) {
const section = header.closest('.trader-section');
section.classList.toggle('collapsed');
persistCollapsed();
}
function setAllTradersCollapsed(collapsed) {
document.querySelectorAll('.trader-section').forEach(section => {
section.classList.toggle('collapsed', collapsed);
});
persistCollapsed();
}
const COLLAPSE_KEY = 'collector.collapsedTraders2';
function persistCollapsed() {
const collapsed = Array.from(document.querySelectorAll('.trader-section.collapsed'))
.map(s => s.id);
localStorage.setItem(COLLAPSE_KEY, JSON.stringify(collapsed));
}
function restoreCollapsed() {
try {
const raw = localStorage.getItem(COLLAPSE_KEY);
if (!raw) return;
const ids = JSON.parse(raw);
if (!Array.isArray(ids)) return;
ids.forEach(id => {
const section = document.getElementById(id);
if (section) section.classList.add('collapsed');
});
} catch (e) {
// ignore storage/parse errors
}
}
restoreCollapsed();
function toggle(btn) {
const node = btn.closest('[data-id]');
const id = node.dataset.id;
const wasDone = node.dataset.done === '1';
const nowDone = wasDone ? 0 : 1;
fetch('/collector/toggle', {
method: 'POST',
body: new URLSearchParams({ quest_id: id, done: nowDone })
})
.then(r => r.json())
.then(() => {
document.querySelectorAll(`[data-id="${id}"]`).forEach(n => {
n.dataset.done = nowDone;
const b = n.querySelector('.toggle-btn');
if (b) {
if (nowDone) { n.classList.add('done'); b.textContent = '✓'; }
else { n.classList.remove('done'); b.textContent = '○'; }
}
});
if (node.dataset.counted === '1') {
doneCount += nowDone ? 1 : -1;
updateProgress();
}
});
}
</script>
</body>
</html>

325
templates/gun_detail.html Normal file
View File

@@ -0,0 +1,325 @@
<!doctype html>
<html>
<head>
<title>OnlyScavs {{ gun.name }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
:root {
--bg: #121212;
--panel: #1a1a1a;
--text: #eee;
--muted: #bbb;
--border: #333;
--accent: #9ccfff;
--amber: #ffd580;
--key-border: #5a7a3a;
--key-bg: #141e10;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
margin: 0;
padding-top: 52px;
background-image: url('/assets/onlyscavs.png');
background-attachment: fixed;
background-size: cover;
background-position: center 65%;
}
body::before {
content: '';
position: fixed;
inset: 0;
background: rgba(14,14,14,0.88);
pointer-events: none;
z-index: 0;
}
.page { max-width: 900px; margin: 0 auto; padding: 24px 16px; position: relative; z-index: 1; }
a { color: var(--accent); }
.site-nav {
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
display: flex; align-items: center; justify-content: space-between;
padding: 0 24px; height: 52px;
background: rgba(14,14,14,0.92);
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(255,255,255,0.06);
}
.nav-brand { font-size: 0.85rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: var(--accent); text-decoration: none; flex-shrink: 0; }
.nav-links { display: flex; gap: 2px; flex-wrap: wrap; }
.nav-links a { color: #666; text-decoration: none; font-size: 0.8rem; padding: 5px 10px; border-radius: 5px; transition: color 0.15s, background 0.15s; }
.nav-links a:hover { color: var(--text); background: rgba(255,255,255,0.06); }
.nav-links a.active { color: var(--accent); background: rgba(156,207,255,0.08); }
h1 { margin: 0 0 2px; font-size: 1.4rem; }
.subtitle { color: var(--muted); font-size: 0.9rem; margin: 0 0 20px; }
/* Gun summary card */
.gun-card {
display: flex;
gap: 16px;
align-items: flex-start;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
margin-bottom: 24px;
}
.gun-card img { width: 96px; height: 96px; object-fit: contain; background: #222; border-radius: 6px; }
.gun-stats { display: flex; gap: 24px; flex-wrap: wrap; margin-top: 8px; }
.stat { display: flex; flex-direction: column; }
.stat .val { font-size: 1.1rem; font-weight: bold; color: var(--amber); }
.stat .lbl { font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; }
.gun-name { flex: 1; }
.gun-name h2 { margin: 0 0 4px; font-size: 1.2rem; }
.gun-name .sub { color: var(--muted); font-size: 0.85rem; margin-bottom: 10px; }
/* Total bar */
.total-bar {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 20px;
margin-bottom: 24px;
display: flex;
align-items: baseline;
gap: 10px;
}
.total-bar .big { font-size: 1.6rem; font-weight: bold; color: var(--amber); }
.total-bar .lbl { color: var(--muted); font-size: 0.9rem; }
.total-bar .breakdown { color: var(--muted); font-size: 0.82rem; margin-left: auto; }
/* Section headers */
.section-hdr {
font-size: 0.78rem;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
padding: 10px 0 6px;
border-bottom: 1px solid var(--border);
margin-bottom: 8px;
}
/* Slot cards */
.slot-section { margin-bottom: 24px; }
.slot-card {
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 10px;
overflow: hidden;
}
.slot-card.key-slot {
border-color: var(--key-border);
background: var(--key-bg);
}
.slot-header {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
cursor: pointer;
user-select: none;
background: rgba(255,255,255,0.03);
}
.slot-header:hover { background: rgba(255,255,255,0.06); }
.slot-header .slot-name { font-weight: bold; font-size: 0.9rem; flex: 1; }
.slot-header .slot-count { color: var(--muted); font-size: 0.8rem; }
.slot-header .slot-lightest { color: var(--amber); font-size: 0.85rem; white-space: nowrap; }
.slot-header .chevron { color: var(--muted); font-size: 0.8rem; transition: transform 0.15s; }
.slot-card.open .chevron { transform: rotate(90deg); }
.required-badge {
font-size: 0.7rem;
background: #2a2a2a;
border: 1px solid #555;
border-radius: 4px;
padding: 1px 5px;
color: var(--muted);
}
.key-badge {
font-size: 0.7rem;
background: #1a3010;
border: 1px solid var(--key-border);
border-radius: 4px;
padding: 1px 5px;
color: #8fc87f;
}
/* Mod list inside slot */
.mod-list { display: none; border-top: 1px solid var(--border); }
.slot-card.open .mod-list { display: block; }
.mod-row {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 12px;
border-bottom: 1px solid #1a1a1a;
font-size: 0.85rem;
}
.mod-row:last-child { border-bottom: none; }
.mod-row:hover { background: rgba(255,255,255,0.03); }
.mod-row img { width: 36px; height: 36px; object-fit: contain; background: #222; border-radius: 4px; flex-shrink: 0; }
.mod-name { flex: 1; }
.mod-name small { display: block; color: var(--muted); font-size: 0.78rem; }
.mod-weight { font-weight: bold; color: var(--amber); white-space: nowrap; min-width: 60px; text-align: right; }
.mod-weight.lightest { color: #8fc87f; }
.mod-wiki { font-size: 0.78rem; color: var(--muted); }
.no-mods { padding: 10px 12px; color: var(--muted); font-size: 0.85rem; font-style: italic; }
/* Toggle other slots */
.toggle-other {
background: none;
border: 1px solid var(--border);
border-radius: 6px;
color: var(--muted);
padding: 5px 14px;
font-size: 0.85rem;
cursor: pointer;
margin-bottom: 12px;
}
.toggle-other:hover { border-color: var(--accent); color: var(--accent); }
#other-slots { display: none; }
#other-slots.visible { display: block; }
</style>
</head>
<body>
<div class="page">
<nav class="site-nav">
<a class="nav-brand" href="/">OnlyScavs</a>
<div class="nav-links">
<a href="/keys">Keys</a>
<a href="/collector">Collector</a>
<a href="/quests">Quests</a>
<a href="/loadout" class="active">Loadout</a>
<a href="/meds">Injectors</a>
<a href="/barters">Barters</a>
</div>
</nav>
<div class="gun-card">
{% if gun.grid_image_url %}
<img src="{{ gun.grid_image_url }}" alt="{{ gun.name }}">
{% endif %}
<div class="gun-name">
<h2>{{ gun.name }}</h2>
<div class="sub">
{{ gun.caliber or '?' }}
{% if gun.wiki_url %}&nbsp;<a href="{{ gun.wiki_url }}" target="_blank">wiki ↗</a>{% endif %}
</div>
<div class="gun-stats">
<div class="stat">
<span class="val">{{ "%.3f"|format(gun.weight_kg) if gun.weight_kg is not none else '?' }}</span>
<span class="lbl">Base weight (kg)</span>
</div>
{% if gun.ergonomics %}
<div class="stat">
<span class="val">{{ gun.ergonomics }}</span>
<span class="lbl">Ergonomics</span>
</div>
{% endif %}
{% if gun.recoil_vertical %}
<div class="stat">
<span class="val">{{ gun.recoil_vertical }}</span>
<span class="lbl">Recoil (V)</span>
</div>
{% endif %}
{% if gun.fire_rate %}
<div class="stat">
<span class="val">{{ gun.fire_rate }}</span>
<span class="lbl">Fire rate</span>
</div>
{% endif %}
</div>
</div>
</div>
<div class="total-bar">
<span class="lbl">Lightest possible build:</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 required slot</span>
</div>
{# ---- Macro to render a slot card ---- #}
{% macro slot_card(slot, extra_class='', open=False) %}
{% set lightest = slot.mods[0] if slot.mods else none %}
<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 }}')">
<span class="slot-name">{{ slot.slot_name }}</span>
{% if extra_class == 'key-slot' %}<span class="key-badge">key slot</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>
{% 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 %}
</div>
{% endif %}
{# ---- Optional slots (collapsed behind toggle) ---- #}
{% if optional_slots %}
<button class="toggle-other" onclick="toggleOther(this)">Show {{ optional_slots | length }} optional slots ▼</button>
<div id="other-slots">
<div class="slot-section">
<div class="section-hdr">Optional slots <span style="font-weight:normal;color:#666">(not counted in lightest build weight)</span></div>
{% for slot in optional_slots %}
{{ slot_card(slot) }}
{% endfor %}
</div>
</div>
{% endif %}
</div>
<script>
function toggleSlot(id) {
document.getElementById(id).classList.toggle('open');
}
function toggleOther(btn) {
const div = document.getElementById('other-slots');
div.classList.toggle('visible');
btn.textContent = div.classList.contains('visible')
? 'Hide optional slots ▲'
: 'Show {{ optional_slots | length }} optional slots ▼';
}
</script>
</body>
</html>

View File

@@ -168,6 +168,17 @@
<body> <body>
<div class="page"> <div class="page">
<nav style="margin-bottom:12px">
<a href="/collector">Collector Checklist</a>
&nbsp;|&nbsp;
<a href="/quests">Quest Trees</a>
&nbsp;|&nbsp;
<a href="/loadout">Loadout Planner</a>
&nbsp;|&nbsp;
<a href="/meds">Injectors</a>
&nbsp;|&nbsp;
<a href="/barters">Barters</a>
</nav>
<h1>OnlyScavs Keys</h1> <h1>OnlyScavs Keys</h1>
<form method="get" class="filters"> <form method="get" class="filters">

382
templates/keys.html Normal file
View File

@@ -0,0 +1,382 @@
<!doctype html>
<html>
<head>
<title>OnlyScavs Key Ratings</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
:root {
--bg: #121212;
--panel: #1a1a1a;
--text: #eee;
--muted: #999;
--border: #2a2a2a;
--accent: #9ccfff;
--accent2: #ffd580;
}
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
margin: 0;
padding-top: 52px;
background-image: url('/assets/onlyscavs.png');
background-attachment: fixed;
background-size: cover;
background-position: center 65%;
}
body::before {
content: '';
position: fixed;
inset: 0;
background: rgba(14,14,14,0.88);
pointer-events: none;
z-index: 0;
}
.page {
max-width: 980px;
margin: 0 auto;
padding: 24px 16px;
position: relative;
z-index: 1;
}
.site-nav {
position: fixed;
top: 0; left: 0; right: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
height: 52px;
background: rgba(14,14,14,0.92);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(255,255,255,0.06);
}
.nav-brand {
font-size: 0.85rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--accent);
text-decoration: none;
flex-shrink: 0;
}
.nav-links {
display: flex;
gap: 2px;
flex-wrap: wrap;
}
.nav-links a {
color: #666;
text-decoration: none;
font-size: 0.8rem;
padding: 5px 10px;
border-radius: 5px;
transition: color 0.15s, background 0.15s;
}
.nav-links a:hover { color: var(--text); background: rgba(255,255,255,0.06); }
.nav-links a.active { color: var(--accent); background: rgba(156,207,255,0.08); }
h1 {
font-size: 1.4rem;
margin: 0 0 16px;
color: var(--accent);
letter-spacing: 0.02em;
}
/* ── filters ───────────────────────────────────────────── */
.filters {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 20px;
}
.filters label { color: var(--muted); font-size: 0.85rem; }
select, input[type="text"] {
background: #222;
color: var(--text);
border: 1px solid #3a3a3a;
border-radius: 6px;
padding: 7px 10px;
font-size: 0.9rem;
}
select:focus, input:focus { outline: 1px solid var(--accent); }
button {
background: #2a2a2a;
color: var(--text);
border: 1px solid #444;
cursor: pointer;
padding: 7px 14px;
border-radius: 6px;
font-size: 0.9rem;
transition: background 0.15s;
}
button:hover { background: #333; }
/* ── save-all bar ──────────────────────────────────────── */
.save-all {
margin: 4px 0 18px;
}
.save-all button {
background: #1d3a52;
border-color: #2d5a7a;
color: var(--accent);
font-weight: 600;
}
.save-all button:hover { background: #254a66; }
/* ── key card ──────────────────────────────────────────── */
.key {
display: grid;
grid-template-columns: 56px 1fr auto;
gap: 12px;
align-items: start;
padding: 14px 10px;
border-bottom: 1px solid var(--border);
transition: background 0.1s;
}
.key:hover { background: #161616; }
.key-thumb {
width: 56px;
height: 56px;
border-radius: 6px;
background: #1a1a1a;
object-fit: contain;
flex-shrink: 0;
}
/* left column: name + tags */
.key-info { min-width: 0; }
.key-title {
display: flex;
align-items: baseline;
gap: 8px;
flex-wrap: wrap;
}
.key-title strong { font-size: 0.95rem; line-height: 1.3; }
.key-title a { color: var(--accent); font-size: 0.8rem; }
.map-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 6px;
}
.map-tag {
background: #1e2e1e;
border: 1px solid #2a3f2a;
color: #8fbc8f;
border-radius: 999px;
padding: 1px 8px;
font-size: 0.75rem;
}
/* right column: controls */
.key-controls {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
justify-content: flex-end;
min-width: 280px;
}
.priority-select {
min-width: 100px;
}
/* priority colour coding */
option[value="4"], select.p4 { color: #ff6b6b; }
option[value="3"], select.p3 { color: var(--accent2); }
option[value="2"], select.p2 { color: #9ccfff; }
option[value="1"], select.p1 { color: var(--muted); }
option[value="0"], select.p0 { color: #555; }
.quest-flag {
display: flex;
align-items: center;
gap: 5px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 6px;
padding: 5px 9px;
font-size: 0.82rem;
color: var(--muted);
white-space: nowrap;
cursor: pointer;
}
.quest-flag input { margin: 0; cursor: pointer; }
.note-input {
flex: 1 1 160px;
min-width: 120px;
max-width: 260px;
font-size: 0.85rem;
padding: 6px 9px;
}
.map-list {
display: flex;
flex-wrap: wrap;
gap: 4px;
width: 100%;
}
.map-checkbox {
display: flex;
align-items: center;
gap: 5px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 6px;
padding: 4px 8px;
font-size: 0.8rem;
cursor: pointer;
}
.map-checkbox input { margin: 0; cursor: pointer; }
.save-btn {
font-size: 0.82rem;
padding: 6px 12px;
white-space: nowrap;
}
a { color: var(--accent); }
@media (max-width: 720px) {
.key {
grid-template-columns: 48px 1fr;
grid-template-rows: auto auto;
}
.key-controls {
grid-column: 1 / -1;
min-width: unset;
justify-content: flex-start;
}
}
@media (max-width: 480px) {
.key { grid-template-columns: 1fr; }
.key-thumb { width: 48px; height: 48px; }
select, input, button { min-height: 38px; font-size: 0.95rem; }
}
</style>
</head>
<body>
<div class="page">
<nav class="site-nav">
<a class="nav-brand" href="/">OnlyScavs</a>
<div class="nav-links">
<a href="/keys" class="active">Keys</a>
<a href="/collector">Collector</a>
<a href="/quests">Quests</a>
<a href="/loadout">Loadout</a>
<a href="/meds">Injectors</a>
<a href="/barters">Barters</a>
</div>
</nav>
<h1>Key Ratings</h1>
<form method="get" class="filters">
<label for="map_id">Map</label>
<select id="map_id" name="map_id">
<option value="">All maps</option>
{% for map in maps %}
<option value="{{ map.id }}" {% if map_filter == map.id %}selected{% endif %}>
{{ map.name }}
</option>
{% endfor %}
</select>
<label for="show">Show</label>
<select id="show" name="show">
<option value="all" {% if show == "all" %}selected{% endif %}>All</option>
<option value="rated" {% if show == "rated" %}selected{% endif %}>Rated</option>
<option value="unrated" {% if show == "unrated" %}selected{% endif %}>Unrated</option>
<option value="quest" {% if show == "quest" %}selected{% endif %}>Quest keys</option>
</select>
<label for="sort">Sort</label>
<select id="sort" name="sort">
<option value="priority_desc" {% if sort == "priority_desc" %}selected{% endif %}>Priority ↓</option>
<option value="priority_asc" {% if sort == "priority_asc" %}selected{% endif %}>Priority ↑</option>
<option value="name_asc" {% if sort == "name_asc" %}selected{% endif %}>Name AZ</option>
<option value="name_desc" {% if sort == "name_desc" %}selected{% endif %}>Name ZA</option>
</select>
<button type="submit">Apply</button>
</form>
<form method="post" action="/rate_all">
{% if map_filter %}<input type="hidden" name="map_id" value="{{ map_filter }}">{% endif %}
{% if sort %}<input type="hidden" name="sort" value="{{ sort }}">{% endif %}
{% if show %}<input type="hidden" name="show" value="{{ show }}">{% endif %}
<div class="save-all">
<button type="submit" name="save_all" value="1">Save all changes</button>
</div>
{% for key in keys %}
{% set selected_maps = key_maps.get(key.id, []) %}
<div class="key" id="key-{{ key.id }}">
<img class="key-thumb" src="{{ key.grid_image_url }}" loading="lazy" alt="">
<div class="key-info">
<div class="key-title">
<strong>{{ key.name }}</strong>
{% if key.wiki_url %}<a href="{{ key.wiki_url }}" target="_blank">wiki ↗</a>{% endif %}
</div>
{% if selected_maps %}
<div class="map-tags">
{% for map in maps %}
{% if map.id in selected_maps %}
<span class="map-tag">{{ map.name }}</span>
{% endif %}
{% endfor %}
</div>
{% endif %}
</div>
<div class="key-controls">
<input type="hidden" name="key_ids" value="{{ key.id }}">
<select name="priority_{{ key.id }}" class="priority-select">
<option value="" {% if key.priority is none %}selected{% endif %}>— unrated</option>
{% for i, label in [(0,'IGNORE'),(1,'LOW'),(2,'MED'),(3,'HIGH'),(4,'SUPER')] %}
<option value="{{ i }}" {% if key.priority == i %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<label class="quest-flag" title="Used in a quest?">
<input type="checkbox" name="used_in_quest_{{ key.id }}" {% if key.used_in_quest %}checked{% endif %}>
<span>Quest</span>
</label>
<input class="note-input" name="reason_{{ key.id }}" placeholder="note…" value="{{ key.reason or '' }}">
<div class="map-list">
{% for map in maps %}
<label class="map-checkbox">
<input type="checkbox" name="map_ids_{{ key.id }}" value="{{ map.id }}"
{% if map.id in selected_maps %}checked{% endif %}>
<span>{{ map.name }}</span>
</label>
{% endfor %}
</div>
<button class="save-btn" type="submit" name="save_one" value="{{ key.id }}">Save</button>
</div>
</div>
{% endfor %}
<div class="save-all">
<button type="submit" name="save_all" value="1">Save all changes</button>
</div>
</form>
</div>
</body>
</html>

295
templates/landing.html Normal file
View File

@@ -0,0 +1,295 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>OnlyScavs</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
:root {
--bg: #0e0e0e;
--panel: #161616;
--panel2: #1c1c1c;
--text: #e8e8e8;
--muted: #777;
--muted2: #555;
--border: #262626;
--accent: #9ccfff;
--accent2: #ffd580;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
/* ── NAV ─────────────────────────────────────────────────── */
.site-nav {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 32px;
height: 52px;
background: rgba(14,14,14,0.88);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(255,255,255,0.06);
}
.nav-brand {
font-size: 0.95rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent);
text-decoration: none;
}
.nav-links {
display: flex;
gap: 4px;
}
.nav-links a {
color: var(--muted);
text-decoration: none;
font-size: 0.82rem;
padding: 6px 12px;
border-radius: 6px;
transition: color 0.15s, background 0.15s;
}
.nav-links a:hover {
color: var(--text);
background: rgba(255,255,255,0.06);
}
/* ── HERO ─────────────────────────────────────────────────── */
.hero {
position: relative;
height: 580px;
overflow: hidden;
display: flex;
align-items: flex-end;
}
.hero-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center 65%;
}
.hero-overlay {
position: absolute;
inset: 0;
background: linear-gradient(
to bottom,
rgba(14,14,14,0.05) 0%,
rgba(14,14,14,0.1) 35%,
rgba(14,14,14,0.7) 68%,
rgba(14,14,14,1) 92%
);
}
.hero-content {
position: relative;
z-index: 1;
padding: 0 48px 48px;
max-width: 860px;
}
.hero-content h1 {
font-size: 3rem;
font-weight: 800;
letter-spacing: -0.01em;
line-height: 1;
color: #fff;
margin-bottom: 12px;
}
.hero-content h1 span {
color: var(--accent);
}
.hero-content p {
font-size: 1.05rem;
color: rgba(255,255,255,0.55);
max-width: 420px;
line-height: 1.55;
}
/* ── TOOLS GRID ───────────────────────────────────────────── */
.tools-section {
max-width: 920px;
margin: 0 auto;
padding: 48px 32px 80px;
}
.section-label {
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--muted2);
margin-bottom: 20px;
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 12px;
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 22px 20px;
text-decoration: none;
color: var(--text);
display: flex;
flex-direction: column;
gap: 6px;
transition: border-color 0.15s, background 0.15s, transform 0.1s;
position: relative;
overflow: hidden;
}
.card::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
background: var(--accent);
opacity: 0;
transition: opacity 0.15s;
}
.card:hover {
border-color: #333;
background: var(--panel2);
transform: translateY(-2px);
}
.card:hover::before {
opacity: 1;
}
.card-icon {
font-size: 1.4rem;
line-height: 1;
margin-bottom: 4px;
}
.card-title {
font-size: 0.95rem;
font-weight: 700;
color: var(--text);
letter-spacing: 0.01em;
}
.card-desc {
font-size: 0.8rem;
color: var(--muted);
line-height: 1.5;
}
.card-arrow {
margin-top: auto;
padding-top: 14px;
font-size: 0.75rem;
color: var(--muted2);
display: flex;
align-items: center;
gap: 4px;
transition: color 0.15s;
}
.card:hover .card-arrow {
color: var(--accent);
}
/* ── FOOTER ───────────────────────────────────────────────── */
footer {
border-top: 1px solid var(--border);
padding: 20px 32px;
text-align: center;
font-size: 0.75rem;
color: var(--muted2);
}
/* ── RESPONSIVE ───────────────────────────────────────────── */
@media (max-width: 640px) {
.site-nav { padding: 0 16px; }
.nav-links { display: none; }
.hero { height: 420px; }
.hero-content { padding: 0 20px 40px; }
.hero-content h1 { font-size: 2rem; }
.tools-section { padding: 32px 16px 60px; }
.cards { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 400px) {
.cards { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<nav class="site-nav">
<a class="nav-brand" href="/">OnlyScavs</a>
<div class="nav-links">
<a href="/keys">Keys</a>
<a href="/collector">Collector</a>
<a href="/quests">Quests</a>
<a href="/loadout">Loadout</a>
<a href="/meds">Injectors</a>
<a href="/barters">Barters</a>
</div>
</nav>
<section class="hero">
<img class="hero-img" src="/assets/onlyscavs.png" alt="OnlyScavs">
<div class="hero-overlay"></div>
<div class="hero-content">
<h1>Only<span>Scavs</span></h1>
<p>Escape from Tarkov reference tools. Keys, quests, loadouts — all local, all yours.</p>
</div>
</section>
<div class="tools-section">
<div class="section-label">Tools</div>
<div class="cards">
<a class="card" href="/keys">
<div class="card-icon">🗝</div>
<div class="card-title">Key Ratings</div>
<div class="card-desc">Rate and filter keys by map, priority, and quest use.</div>
<div class="card-arrow">Open →</div>
</a>
<a class="card" href="/collector">
<div class="card-icon"></div>
<div class="card-title">Collector</div>
<div class="card-desc">Track your Kappa container progress across all 255 quests.</div>
<div class="card-arrow">Open →</div>
</a>
<a class="card" href="/quests">
<div class="card-icon">📋</div>
<div class="card-title">Quest Trees</div>
<div class="card-desc">Visualize quest chains and trader dependencies.</div>
<div class="card-arrow">Open →</div>
</a>
<a class="card" href="/loadout">
<div class="card-icon">🎽</div>
<div class="card-title">Loadout Planner</div>
<div class="card-desc">Browse and compare guns, armor, rigs, and more.</div>
<div class="card-arrow">Open →</div>
</a>
<a class="card" href="/meds">
<div class="card-icon">💉</div>
<div class="card-title">Injectors</div>
<div class="card-desc">Compare stim effects, skills, and side effects.</div>
<div class="card-arrow">Open →</div>
</a>
<a class="card" href="/barters">
<div class="card-icon">🔄</div>
<div class="card-title">Barter Calculator</div>
<div class="card-desc">Calculate the true rouble cost of any barter deal.</div>
<div class="card-arrow">Open →</div>
</a>
</div>
</div>
<footer>
OnlyScavs — personal Tarkov toolkit
</footer>
</body>
</html>

997
templates/loadout.html Normal file
View File

@@ -0,0 +1,997 @@
<!doctype html>
<html>
<head>
<title>OnlyScavs Loadout Planner</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
:root {
--bg: #121212;
--panel: #1a1a1a;
--text: #eee;
--muted: #bbb;
--border: #333;
--accent: #9ccfff;
--amber: #ffd580;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
margin: 0;
padding-top: 52px;
background-image: url('/assets/onlyscavs.png');
background-attachment: fixed;
background-size: cover;
background-position: center 65%;
}
body::before {
content: '';
position: fixed;
inset: 0;
background: rgba(14,14,14,0.88);
pointer-events: none;
z-index: 0;
}
.page { max-width: 1100px; margin: 0 auto; padding: 24px 16px; position: relative; z-index: 1; }
h1 { margin-bottom: 4px; }
a { color: var(--accent); }
.site-nav {
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
display: flex; align-items: center; justify-content: space-between;
padding: 0 24px; height: 52px;
background: rgba(14,14,14,0.92);
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(255,255,255,0.06);
}
.nav-brand { font-size: 0.85rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: var(--accent); text-decoration: none; flex-shrink: 0; }
.nav-links { display: flex; gap: 2px; flex-wrap: wrap; }
.nav-links a { color: #666; text-decoration: none; font-size: 0.8rem; padding: 5px 10px; border-radius: 5px; transition: color 0.15s, background 0.15s; }
.nav-links a:hover { color: var(--text); background: rgba(255,255,255,0.06); }
.nav-links a.active { color: var(--accent); background: rgba(156,207,255,0.08); }
/* Tab bar */
.tab-bar {
display: flex;
gap: 2px;
margin-bottom: 20px;
border-bottom: 2px solid var(--border);
flex-wrap: wrap;
}
.tab-bar a {
text-decoration: none;
color: var(--muted);
padding: 8px 16px;
font-size: 0.95rem;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
}
.tab-bar a.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.tab-bar a:hover { color: var(--text); }
/* Filter bar */
.filter-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 16px;
}
.filter-bar label { color: var(--muted); font-size: 0.9rem; }
.filter-bar select, .filter-bar input[type=number] {
background: #222;
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
padding: 4px 8px;
font-size: 0.9rem;
}
.filter-bar button {
background: #2a2a2a;
color: var(--text);
border: 1px solid #444;
border-radius: 6px;
padding: 4px 12px;
cursor: pointer;
font-size: 0.9rem;
}
.filter-bar button:hover { border-color: var(--accent); }
/* Slot filter checkboxes */
.slot-check {
display: inline-flex;
align-items: center;
gap: 5px;
background: #222;
border: 1px solid var(--border);
border-radius: 6px;
padding: 3px 9px;
font-size: 0.85rem;
cursor: pointer;
color: var(--muted);
}
.slot-check.active {
border-color: var(--accent);
color: var(--accent);
}
.slot-check input { cursor: pointer; }
/* Gear table */
.gear-table {
width: 100%;
border-collapse: collapse;
font-size: 0.88rem;
}
.gear-table th {
text-align: left;
padding: 6px 10px;
color: var(--muted);
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.06em;
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
.gear-table td {
padding: 7px 10px;
border-bottom: 1px solid #1e1e1e;
vertical-align: middle;
}
.gear-table tr:hover td { background: #1c1c1c; }
.gear-table img {
width: 48px;
height: 48px;
border-radius: 4px;
background: #222;
object-fit: contain;
}
.w { font-weight: bold; color: var(--amber); white-space: nowrap; }
.muted { color: var(--muted); }
.name-cell strong { display: block; }
.name-cell small { color: var(--muted); font-size: 0.8rem; }
/* Armor class badges */
.cls {
display: inline-block;
padding: 2px 7px;
border-radius: 4px;
font-size: 0.8rem;
font-weight: bold;
background: #2a2a2a;
border: 1px solid #444;
}
.cls-1, .cls-2 { border-color: #444; color: #aaa; }
.cls-3 { border-color: #5a7a3a; color: #8fc87f; }
.cls-4 { border-color: #3a6a8a; color: #7fc4e8; }
.cls-5 { border-color: #7a4a8a; color: #c090e0; }
.cls-6 { border-color: #8a4a3a; color: #e09070; }
/* Empty state */
.empty {
color: var(--muted);
padding: 28px 14px;
font-size: 0.95rem;
}
/* Build builder */
.builder-total {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px 24px;
text-align: center;
margin-bottom: 20px;
}
.builder-total .big { font-size: 2rem; font-weight: bold; color: var(--amber); }
.builder-total .label { color: var(--muted); font-size: 0.9rem; }
.builder-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
margin-bottom: 20px;
}
.slot-card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px;
}
.slot-card h3 {
margin: 0 0 8px;
font-size: 0.8rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.07em;
}
.slot-card select {
width: 100%;
background: #222;
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
padding: 5px 8px;
font-size: 0.85rem;
}
.slot-weight { margin-top: 6px; font-size: 0.85rem; color: var(--amber); min-height: 1.2em; }
.save-row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.save-row input[type=text] {
background: #222;
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
padding: 5px 10px;
font-size: 0.9rem;
width: 200px;
}
.save-row button {
background: #2a2a2a;
color: var(--text);
border: 1px solid #444;
border-radius: 6px;
padding: 5px 14px;
cursor: pointer;
font-size: 0.9rem;
}
.save-row button:hover { border-color: var(--accent); }
.save-status { color: var(--muted); font-size: 0.85rem; }
</style>
</head>
<body>
<div class="page">
<nav class="site-nav">
<a class="nav-brand" href="/">OnlyScavs</a>
<div class="nav-links">
<a href="/keys">Keys</a>
<a href="/collector">Collector</a>
<a href="/quests">Quests</a>
<a href="/loadout" class="active">Loadout</a>
<a href="/meds">Injectors</a>
<a href="/barters">Barters</a>
</div>
</nav>
<h1>Loadout Planner</h1>
<p style="color:var(--muted);margin:0 0 16px;font-size:0.9rem;">
Find the lightest gear for each slot. Filter by requirements.
</p>
<div class="tab-bar">
{% for t_id, t_label in [('guns','Guns'),('armor','Armor'),('helmets','Helmets'),('headwear','Headwear'),('backpacks','Backpacks'),('rigs','Soft Rigs'),('armored_rigs','Armored Rigs'),('plates','Plates'),('builder','Build Builder')] %}
<a href="/loadout?tab={{ t_id }}" class="{% if tab == t_id %}active{% endif %}">{{ t_label }}</a>
{% endfor %}
</div>
{# =============================== GUNS TAB =============================== #}
{% if tab == "guns" %}
<form method="get" class="filter-bar">
<input type="hidden" name="tab" value="guns">
<span style="color:var(--muted);font-size:0.85rem;">Must have slot:</span>
{% for label, nameid in slot_filters %}
<label class="slot-check {% if nameid in requires %}active{% endif %}">
<input type="checkbox" name="requires" value="{{ nameid }}"
{% if nameid in requires %}checked{% endif %}>
{{ label }}
</label>
{% endfor %}
<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="name_asc" {% if sort=='name_asc' %}selected{% endif %}>Name A→Z</option>
</select>
<button type="submit">Filter</button>
{% if requires %}<a href="/loadout?tab=guns" style="font-size:0.85rem;color:var(--muted)">clear</a>{% endif %}
</form>
{% if requires %}
<p style="color:var(--muted);font-size:0.85rem;margin-bottom:12px;">
"Lightest build" = gun base weight + lightest compatible mod per required slot.
Guns without all required slots are hidden.
</p>
{% endif %}
<table class="gear-table">
<thead>
<tr>
<th></th>
<th>Name</th>
<th>Caliber</th>
<th>Ergo</th>
<th title="Vertical recoil">Recoil</th>
<th>Base weight</th>
<th>{% if requires %}Lightest build{% else %}Slots{% endif %}</th>
</tr>
</thead>
<tbody>
{% for gun in guns %}
<tr class="gun-row" data-gun-id="{{ gun.id }}" onclick="toggleGunRow(this)" style="cursor:pointer">
<td>
{% if gun.grid_image_url %}
<img src="{{ gun.grid_image_url }}" loading="lazy" alt="">
{% endif %}
</td>
<td class="name-cell">
<strong>{{ gun.short_name or gun.name }}</strong>
{% if gun.short_name and gun.short_name != gun.name %}
<small>{{ gun.name }}</small>
{% endif %}
</td>
<td class="muted">{{ gun.caliber or '—' }}</td>
<td class="muted">{{ gun.ergonomics or '—' }}</td>
<td class="muted">{{ gun.recoil_vertical or '—' }}</td>
<td class="w">
{% if gun.weight_kg is not none %}{{ "%.3f"|format(gun.weight_kg) }} kg{% else %}—{% endif %}
</td>
<td>
{% if requires %}
<span class="w">{% if gun.lightest_build_weight is not none %}{{ "%.3f"|format(gun.lightest_build_weight) }} kg{% else %}—{% endif %}</span>
{% else %}
<span class="muted" style="font-size:0.8rem">▶ expand</span>
{% endif %}
</td>
</tr>
<tr class="gun-expand-row" id="expand-{{ gun.id }}" style="display:none">
<td colspan="7" style="padding:0">
<div class="gun-expand-inner" style="padding:10px 14px;background:#151515;border-bottom:1px solid var(--border)">
<div class="expand-loading" style="color:var(--muted);font-size:0.85rem">Loading slots…</div>
</div>
</td>
</tr>
{% else %}
<tr><td colspan="7" class="empty">No guns found matching those requirements.</td></tr>
{% endfor %}
</tbody>
</table>
<style>
.gun-row:hover td { background: #1c1c1c; }
.gun-row.expanded td { background: #181818; }
.slot-summary { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 10px; }
.slot-pill {
display: flex; flex-direction: column;
background: #1e1e1e; border: 1px solid var(--border);
border-radius: 6px; padding: 5px 10px; font-size: 0.8rem; min-width: 110px;
}
.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-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; }
.expand-footer { display: flex; align-items: center; gap: 14px; margin-top: 8px; padding-top: 8px; border-top: 1px solid #222; }
.expand-total { color: var(--amber); font-weight: bold; font-size: 0.9rem; }
.expand-link { font-size: 0.82rem; }
</style>
<script>
const _gunSlotCache = {};
function toggleGunRow(tr) {
const gunId = tr.dataset.gunId;
const expandRow = document.getElementById('expand-' + gunId);
const isOpen = expandRow.style.display !== 'none';
if (isOpen) {
expandRow.style.display = 'none';
tr.classList.remove('expanded');
return;
}
tr.classList.add('expanded');
expandRow.style.display = '';
if (_gunSlotCache[gunId]) {
renderGunExpand(gunId, _gunSlotCache[gunId]);
return;
}
fetch('/loadout/gun/' + gunId + '/slots.json')
.then(r => r.json())
.then(data => {
_gunSlotCache[gunId] = data;
renderGunExpand(gunId, data);
})
.catch(() => {
const inner = expandRow.querySelector('.gun-expand-inner');
inner.innerHTML = '<span style="color:var(--muted)">Failed to load slots.</span>';
});
}
function renderGunExpand(gunId, slots) {
const inner = document.getElementById('expand-' + gunId).querySelector('.gun-expand-inner');
if (!slots.length) {
inner.innerHTML = '<span style="color:var(--muted);font-size:0.85rem">No slot data available for this gun.</span>';
return;
}
const KEY = new Set(['mod_muzzle', 'mod_magazine']);
let baseWeight = 0;
const baseCell = document.querySelector('[data-gun-id="' + gunId + '"] td:nth-child(6)');
if (baseCell) baseWeight = parseFloat(baseCell.textContent) || 0;
// Only sum required slots for the lightest build weight
let total = baseWeight;
let reqPills = '';
let optPills = '';
for (const s of slots) {
const isKey = KEY.has(s.slot_nameid);
const w = s.weight_kg != null ? s.weight_kg.toFixed(3) + ' kg' : '—';
if (s.required) {
if (s.weight_kg != null) total += s.weight_kg;
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-mod">${s.mod_name || '—'}</span>
<span class="sp-w">${w}</span>
</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 = `
<div class="slot-summary">${reqPills || '<span style="color:var(--muted);font-size:0.82rem">No required slots</span>'}</div>
${optSection}
<div class="expand-footer">
<span class="expand-total">Lightest build (required slots): ${total.toFixed(3)} kg</span>
<a class="expand-link" href="/loadout/gun/${gunId}">Full breakdown →</a>
</div>`;
}
</script>
{% endif %}
{# =============================== ARMOR TAB =============================== #}
{% if tab == "armor" %}
<form method="get" class="filter-bar">
<input type="hidden" name="tab" value="armor">
<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 armor %}
<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 %}
{% if item.id in carrier_ids_with_open_slots %}<br><small class="muted">no plates</small>{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="7" class="empty">No armor found.</td></tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{# =============================== HELMETS TAB =============================== #}
{% if tab == "helmets" %}
<form method="get" class="filter-bar">
<input type="hidden" name="tab" value="helmets">
<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>Head zones</th><th>Deafening</th><th>Weight</th>
</tr>
</thead>
<tbody>
{% for item in helmets %}
<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 %}<span class="muted"></span>{% endif %}
</td>
<td class="muted">{{ item.durability | int if item.durability else '—' }}</td>
<td class="muted" style="font-size:0.8rem">{{ item.head_zones or '—' }}</td>
<td class="muted">{{ item.deafening 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 helmets found.</td></tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{# =============================== HEADWEAR TAB =============================== #}
{% if tab == "headwear" %}
<form method="get" class="filter-bar">
<input type="hidden" name="tab" value="headwear">
<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>
<p style="color:var(--muted);font-size:0.85rem;margin-bottom:12px;">Face masks, armored masks, and non-helmet head protection. Does not cover the top of the head.</p>
<table class="gear-table">
<thead>
<tr>
<th></th><th>Name</th><th>Class</th>
<th>Durability</th><th>Head zones</th><th>Weight</th>
</tr>
</thead>
<tbody>
{% for item in headwear %}
<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 %}<span class="muted"></span>{% endif %}
</td>
<td class="muted">{{ item.durability | int if item.durability else '—' }}</td>
<td class="muted" style="font-size:0.8rem">{{ item.head_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="6" class="empty">No headwear found.</td></tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{# =============================== BACKPACKS TAB =============================== #}
{% if tab == "backpacks" %}
<form method="get" class="filter-bar">
<input type="hidden" name="tab" value="backpacks">
<label>Min slots</label>
<input type="number" name="min_capacity" value="{{ min_capacity or '' }}" min="0" placeholder="0" style="width:60px">
<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="capacity_desc" {% if sort=='capacity_desc' %}selected{% endif %}>Capacity ↓</option>
<option value="capacity_asc" {% if sort=='capacity_asc' %}selected{% endif %}>Capacity ↑</option>
<option value="efficiency_desc" {% if sort=='efficiency_desc' %}selected{% endif %}>Carry Efficiency ↓</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>Capacity (slots)</th><th>Weight</th><th>Carry Efficiency</th>
</tr>
</thead>
<tbody>
{% for item in backpacks %}
<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 class="muted">{{ item.capacity or '—' }}</td>
<td class="w">{% if item.weight_kg is not none %}{{ "%.3f"|format(item.weight_kg) }} kg{% else %}—{% endif %}</td>
<td class="muted">
{% if item.slots_per_kg is not none %}
<span title="{{ "%.3f"|format(item.kg_per_slot) }} kg/slot">{{ "%.2f"|format(item.slots_per_kg) }} slots/kg</span>
{% else %}—{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="5" class="empty">No backpacks found.</td></tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{# =============================== SOFT RIGS TAB =============================== #}
{% if tab == "rigs" %}
<form method="get" class="filter-bar">
<input type="hidden" name="tab" value="rigs">
<label>Min slots</label>
<input type="number" name="min_capacity" value="{{ min_capacity or '' }}" min="0" placeholder="0" style="width:60px">
<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="capacity_desc" {% if sort=='capacity_desc' %}selected{% endif %}>Capacity ↓</option>
<option value="efficiency_desc" {% if sort=='efficiency_desc' %}selected{% endif %}>Carry Efficiency ↓</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>Capacity (slots)</th><th>Weight</th><th>Carry Efficiency</th>
</tr>
</thead>
<tbody>
{% for item in rigs %}
<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 class="muted">{{ item.capacity or '—' }}</td>
<td class="w">{% if item.weight_kg is not none %}{{ "%.3f"|format(item.weight_kg) }} kg{% else %}—{% endif %}</td>
<td class="muted">
{% if item.slots_per_kg is not none %}
<span title="{{ "%.3f"|format(item.kg_per_slot) }} kg/slot">{{ "%.2f"|format(item.slots_per_kg) }} slots/kg</span>
{% else %}—{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="5" class="empty">No soft rigs found.</td></tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{# =============================== ARMORED RIGS TAB =============================== #}
{% if tab == "armored_rigs" %}
<form method="get" class="filter-bar">
<input type="hidden" name="tab" value="armored_rigs">
<label>Min slots</label>
<input type="number" name="min_capacity" value="{{ min_capacity or '' }}" min="0" placeholder="0" style="width:60px">
<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="capacity_desc" {% if sort=='capacity_desc' %}selected{% endif %}>Capacity ↓</option>
<option value="class_desc" {% if sort=='class_desc' %}selected{% endif %}>Class ↓</option>
<option value="efficiency_desc" {% if sort=='efficiency_desc' %}selected{% endif %}>Carry Efficiency ↓</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>Capacity (slots)</th><th>Zones</th><th>Weight</th><th>Carry Efficiency</th>
</tr>
</thead>
<tbody>
{% for item in armored_rigs %}
<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>
<span class="cls cls-{{ item.armor_class }}">{{ item.armor_class }}</span>
</td>
<td class="muted">{{ item.capacity 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 %}
{% if item.id in carrier_ids_with_open_slots %}<br><small class="muted">no plates</small>{% endif %}
</td>
<td class="muted">
{% if item.slots_per_kg is not none %}
<span title="{{ "%.3f"|format(item.kg_per_slot) }} kg/slot">{{ "%.2f"|format(item.slots_per_kg) }} slots/kg</span>
{% else %}—{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="7" class="empty">No armored rigs found.</td></tr>
{% endfor %}
</tbody>
</table>
{% 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 =============================== #}
{% if tab == "builder" %}
<script>
const WEIGHTS = {
{% for item in builder_guns + builder_armor + builder_helmets + builder_rigs + builder_backpacks %}
"{{ item.id }}": {{ item.weight_kg if item.weight_kg is not none else 0 }},
{% 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() {
const slots = ['gun', 'armor', 'helmet', 'rig', 'backpack'];
let total = 0;
for (const slot of slots) {
const sel = document.getElementById('slot_' + slot);
const id = sel ? sel.value : '';
const w = id ? (WEIGHTS[id] || 0) : 0;
const disp = document.getElementById('sw_' + slot);
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;
}
// Add plate weights
for (const pw of Object.values(_plateSlotWeights)) {
total += pw;
}
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() {
const slots = ['gun', 'armor', 'helmet', 'rig', 'backpack'];
const payload = { name: document.getElementById('build-name').value.trim() || 'My Build' };
for (const s of slots) {
const sel = document.getElementById('slot_' + s);
payload[s + '_id'] = (sel && sel.value) ? sel.value : null;
}
fetch('/loadout/save-build', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
.then(r => r.json())
.then(d => {
document.getElementById('save-status').textContent = 'Saved as "' + d.name + '" (build #' + d.build_id + ')';
})
.catch(() => {
document.getElementById('save-status').textContent = 'Error saving build.';
});
}
</script>
<div class="builder-total">
<div class="label">Total loadout weight</div>
<div class="big"><span id="total-weight">0.000</span> kg</div>
</div>
<div class="builder-grid">
{% set slot_defs = [
('gun', 'Primary Weapon', builder_guns, false),
('armor', 'Body Armor', builder_armor, true),
('helmet', 'Helmet', builder_helmets, false),
('rig', 'Chest Rig', builder_rigs, true),
('backpack', 'Backpack', builder_backpacks, false),
] %}
{% for slot_id, slot_label, items, has_plates in slot_defs %}
<div class="slot-card">
<h3>{{ slot_label }}</h3>
<select id="slot_{{ slot_id }}" onchange="{% if has_plates %}onCarrierChange('{{ slot_id }}'){% else %}recalcWeight(){% endif %}">
<option value="">— None —</option>
{% for item in items %}
<option value="{{ item.id }}">
{{ 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>
{% endfor %}
</select>
<div class="slot-weight" id="sw_{{ slot_id }}"></div>
{% if has_plates %}<div id="plates_{{ slot_id }}"></div>{% endif %}
</div>
{% endfor %}
</div>
<div class="save-row">
<input type="text" id="build-name" placeholder="Build name…">
<button onclick="saveBuild()">Save Build</button>
<span class="save-status" id="save-status"></span>
</div>
{% endif %}
</div>
</body>
</html>

585
templates/meds.html Normal file
View File

@@ -0,0 +1,585 @@
<!doctype html>
<html>
<head>
<title>OnlyScavs Injector Reference</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
:root {
--bg: #121212;
--panel: #1a1a1a;
--panel2: #1e1e1e;
--text: #eee;
--muted: #888;
--border: #2a2a2a;
--accent: #9ccfff;
--good: #6ec96e;
--bad: #e06060;
--warn: #e0a040;
--neutral: #aaa;
--heal: #5db8a0;
--stam: #6abfdb;
--skill: #a58cf0;
--special: #c8a850;
}
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
margin: 0;
padding-top: 52px;
background-image: url('/assets/onlyscavs.png');
background-attachment: fixed;
background-size: cover;
background-position: center 65%;
}
body::before {
content: '';
position: fixed;
inset: 0;
background: rgba(14,14,14,0.88);
pointer-events: none;
z-index: 0;
}
.page { max-width: 1200px; margin: 0 auto; padding: 24px 16px; position: relative; z-index: 1; }
.site-nav {
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
display: flex; align-items: center; justify-content: space-between;
padding: 0 24px; height: 52px;
background: rgba(14,14,14,0.92);
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(255,255,255,0.06);
}
.nav-brand { font-size: 0.85rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: var(--accent); text-decoration: none; flex-shrink: 0; }
.nav-links { display: flex; gap: 2px; flex-wrap: wrap; }
.nav-links a { color: #666; text-decoration: none; font-size: 0.8rem; padding: 5px 10px; border-radius: 5px; transition: color 0.15s, background 0.15s; }
.nav-links a:hover { color: var(--text); background: rgba(255,255,255,0.06); }
.nav-links a.active { color: var(--accent); background: rgba(156,207,255,0.08); }
h1 { font-size: 1.4rem; margin: 0 0 4px; }
.subtitle { color: var(--muted); font-size: 0.88rem; margin: 0 0 28px; }
/* ── Section headings ── */
.section-head {
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--muted);
border-bottom: 1px solid var(--border);
padding-bottom: 6px;
margin: 32px 0 16px;
}
/* ════════════════════════════════════
SITUATION GUIDE
════════════════════════════════════ */
.sit-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 12px;
}
.sit-card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px 16px;
}
.sit-label {
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 6px;
}
.sit-label span.icon { font-size: 1rem; }
.sit-item {
display: flex;
align-items: baseline;
gap: 8px;
padding: 5px 0;
border-bottom: 1px solid var(--border);
font-size: 0.85rem;
}
.sit-item:last-child { border-bottom: none; }
.sit-name {
font-weight: 600;
min-width: 90px;
color: var(--accent);
white-space: nowrap;
}
.sit-desc { color: var(--muted); font-size: 0.8rem; flex: 1; }
.sit-warn { color: var(--bad); font-size: 0.75rem; white-space: nowrap; }
/* ════════════════════════════════════
COMPARISON GRID
════════════════════════════════════ */
.grid-wrap {
overflow-x: auto;
border: 1px solid var(--border);
border-radius: 8px;
}
table.comp {
border-collapse: collapse;
width: 100%;
min-width: 900px;
font-size: 0.82rem;
}
table.comp thead th {
background: var(--panel2);
padding: 10px 8px;
text-align: center;
border-right: 1px solid var(--border);
border-bottom: 2px solid var(--border);
font-size: 0.75rem;
white-space: nowrap;
}
table.comp thead th:first-child {
text-align: left;
padding-left: 14px;
min-width: 160px;
background: #151515;
}
table.comp thead .th-icon {
display: block;
margin: 0 auto 4px;
width: 32px;
height: 32px;
object-fit: contain;
}
table.comp thead .th-name {
display: block;
font-weight: 700;
color: var(--accent);
}
table.comp thead .th-short {
display: block;
color: var(--muted);
font-size: 0.7rem;
}
table.comp tbody tr { border-bottom: 1px solid var(--border); }
table.comp tbody tr:last-child { border-bottom: none; }
table.comp tbody tr:hover { background: rgba(255,255,255,0.03); }
table.comp tbody td {
padding: 7px 8px;
text-align: center;
border-right: 1px solid var(--border);
vertical-align: middle;
}
table.comp tbody td:first-child {
text-align: left;
padding-left: 14px;
color: var(--muted);
font-size: 0.78rem;
font-weight: 600;
background: #151515;
white-space: nowrap;
}
table.comp tbody tr.row-group td:first-child {
font-size: 0.68rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #555;
padding-top: 10px;
padding-bottom: 4px;
border-bottom: none;
background: #111;
}
table.comp tbody tr.row-group td {
background: #111;
border-bottom: none;
}
/* value cells */
.v-good { color: var(--good); font-weight: 600; }
.v-bad { color: var(--bad); }
.v-warn { color: var(--warn); }
.v-none { color: #383838; }
.v-heal { color: var(--heal); font-weight: 600; }
.v-stam { color: var(--stam); font-weight: 600; }
.v-skill { color: var(--skill); }
.v-special { color: var(--special); }
/* check / cross / dash symbols */
.sym-check { color: var(--good); font-size: 1rem; }
.sym-x { color: var(--bad); font-size: 1rem; }
.sym-dash { color: #333; }
/* duration pill */
.dur {
display: inline-block;
background: #252525;
border-radius: 3px;
padding: 1px 5px;
font-size: 0.72rem;
color: var(--muted);
}
.dur-long { background: #1e2a1e; color: var(--good); }
.dur-med { background: #252020; color: var(--warn); }
.dur-short { background: #2a1818; color: var(--bad); }
/* wiki link in header */
table.comp thead th a {
color: var(--muted);
font-size: 0.65rem;
text-decoration: none;
display: block;
}
table.comp thead th a:hover { color: var(--accent); }
/* ── filter tabs ── */
.filter-bar {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.filter-btn {
background: var(--panel);
border: 1px solid var(--border);
color: var(--muted);
border-radius: 4px;
padding: 4px 12px;
font-size: 0.78rem;
cursor: pointer;
}
.filter-btn:hover { color: var(--text); border-color: #555; }
.filter-btn.active { color: var(--accent); border-color: var(--accent); background: #1a2533; }
</style>
</head>
<body>
<div class="page">
<nav class="site-nav">
<a class="nav-brand" href="/">OnlyScavs</a>
<div class="nav-links">
<a href="/keys">Keys</a>
<a href="/collector">Collector</a>
<a href="/quests">Quests</a>
<a href="/loadout">Loadout</a>
<a href="/meds" class="active">Injectors</a>
<a href="/barters">Barters</a>
</div>
</nav>
<h1>Injector Quick Reference</h1>
<p class="subtitle">All stim injectors — situation guide + full effect comparison</p>
<!-- ══════════════════════════════════
SITUATION GUIDE
══════════════════════════════════ -->
<div class="section-head">Situation Guide — what to grab when</div>
<div class="sit-grid">
<div class="sit-card">
<div class="sit-label" style="color: var(--heal)"><span class="icon">🩸</span> Bleeding / Wound</div>
{% for inj in situations.bleed %}
<div class="sit-item">
<span class="sit-name">{{ inj.short }}</span>
<span class="sit-desc">{{ inj.desc }}</span>
{% if inj.warn %}<span class="sit-warn">⚠ {{ inj.warn }}</span>{% endif %}
</div>
{% endfor %}
</div>
<div class="sit-card">
<div class="sit-label" style="color: var(--heal)"><span class="icon">❤️</span> HP Regen</div>
{% for inj in situations.regen %}
<div class="sit-item">
<span class="sit-name">{{ inj.short }}</span>
<span class="sit-desc">{{ inj.desc }}</span>
{% if inj.warn %}<span class="sit-warn">⚠ {{ inj.warn }}</span>{% endif %}
</div>
{% endfor %}
</div>
<div class="sit-card">
<div class="sit-label" style="color: var(--stam)"><span class="icon"></span> Stamina / Speed</div>
{% for inj in situations.stam %}
<div class="sit-item">
<span class="sit-name">{{ inj.short }}</span>
<span class="sit-desc">{{ inj.desc }}</span>
{% if inj.warn %}<span class="sit-warn">⚠ {{ inj.warn }}</span>{% endif %}
</div>
{% endfor %}
</div>
<div class="sit-card">
<div class="sit-label" style="color: var(--skill)"><span class="icon">💪</span> Skill Boosts</div>
{% for inj in situations.skill %}
<div class="sit-item">
<span class="sit-name">{{ inj.short }}</span>
<span class="sit-desc">{{ inj.desc }}</span>
{% if inj.warn %}<span class="sit-warn">⚠ {{ inj.warn }}</span>{% endif %}
</div>
{% endfor %}
</div>
<div class="sit-card">
<div class="sit-label" style="color: var(--special)"><span class="icon">🌡️</span> Temperature / Special</div>
{% for inj in situations.special %}
<div class="sit-item">
<span class="sit-name">{{ inj.short }}</span>
<span class="sit-desc">{{ inj.desc }}</span>
{% if inj.warn %}<span class="sit-warn">⚠ {{ inj.warn }}</span>{% endif %}
</div>
{% endfor %}
</div>
<div class="sit-card">
<div class="sit-label" style="color: var(--bad)"><span class="icon">☠️</span> High Risk / Gamble</div>
{% for inj in situations.risky %}
<div class="sit-item">
<span class="sit-name">{{ inj.short }}</span>
<span class="sit-desc">{{ inj.desc }}</span>
{% if inj.warn %}<span class="sit-warn">⚠ {{ inj.warn }}</span>{% endif %}
</div>
{% endfor %}
</div>
</div>
<!-- ══════════════════════════════════
COMPARISON GRID
══════════════════════════════════ -->
<div class="section-head" style="margin-top:40px">Full Comparison Grid</div>
<div class="filter-bar" id="filterBar">
<button class="filter-btn active" data-filter="all">All</button>
<button class="filter-btn" data-filter="heal">Healing</button>
<button class="filter-btn" data-filter="stam">Stamina</button>
<button class="filter-btn" data-filter="skill">Skill</button>
<button class="filter-btn" data-filter="special">Special</button>
</div>
<div class="grid-wrap">
<table class="comp" id="compTable">
<thead>
<tr>
<th>Effect</th>
{% for inj in injectors %}
<th data-tags="{{ inj.tags }}">
{% if inj.icon %}<img class="th-icon" src="{{ inj.icon }}" alt="" onerror="this.style.display='none'">{% endif %}
<span class="th-name">{{ inj.short }}</span>
{% if inj.wiki %}<a href="{{ inj.wiki }}" target="_blank">wiki ↗</a>{% endif %}
</th>
{% endfor %}
</tr>
</thead>
<tbody>
<!-- HP REGENERATION -->
<tr class="row-group">
<td>Healing</td>
{% for inj in injectors %}<td></td>{% endfor %}
</tr>
<tr>
<td>HP regen /s</td>
{% for inj in injectors %}
<td>
{% if inj.hp_regen %}
<span class="v-heal">+{{ inj.hp_regen }}</span>
<span class="dur {% if inj.hp_regen_dur >= 300 %}dur-long{% elif inj.hp_regen_dur >= 60 %}dur-med{% else %}dur-short{% endif %}">{{ inj.hp_regen_dur }}s</span>
{% else %}<span class="sym-dash"></span>{% endif %}
</td>
{% endfor %}
</tr>
<tr>
<td>Stops bleeding</td>
{% for inj in injectors %}
<td>
{% if inj.stops_bleed %}<span class="sym-check"></span>{% else %}<span class="sym-dash"></span>{% endif %}
</td>
{% endfor %}
</tr>
<tr>
<td>Antidote</td>
{% for inj in injectors %}
<td>
{% if inj.antidote %}<span class="sym-check"></span>{% else %}<span class="sym-dash"></span>{% endif %}
</td>
{% endfor %}
</tr>
<!-- STAMINA -->
<tr class="row-group">
<td>Stamina</td>
{% for inj in injectors %}<td></td>{% endfor %}
</tr>
<tr>
<td>Max stamina</td>
{% for inj in injectors %}
<td>
{% if inj.max_stam %}
<span class="{% if inj.max_stam > 0 %}v-stam{% else %}v-bad{% endif %}">
{{ '+' if inj.max_stam > 0 else '' }}{{ inj.max_stam }}
</span>
<span class="dur">{{ inj.max_stam_dur }}s</span>
{% else %}<span class="sym-dash"></span>{% endif %}
</td>
{% endfor %}
</tr>
<tr>
<td>Stam recovery /s</td>
{% for inj in injectors %}
<td>
{% if inj.stam_rec %}
<span class="{% if inj.stam_rec > 0 %}v-stam{% else %}v-bad{% endif %}">
{{ '+' if inj.stam_rec > 0 else '' }}{{ inj.stam_rec }}
</span>
<span class="dur">{{ inj.stam_rec_dur }}s</span>
{% else %}<span class="sym-dash"></span>{% endif %}
</td>
{% endfor %}
</tr>
<tr>
<td>Weight limit</td>
{% for inj in injectors %}
<td>
{% if inj.weight %}
<span class="{% if inj.weight > 0 %}v-good{% else %}v-bad{% endif %}">
{{ '+' if inj.weight > 0 else '' }}{{ (inj.weight * 100)|int }}%
</span>
<span class="dur">{{ inj.weight_dur }}s</span>
{% else %}<span class="sym-dash"></span>{% endif %}
</td>
{% endfor %}
</tr>
<!-- SKILLS -->
<tr class="row-group">
<td>Skill Buffs</td>
{% for inj in injectors %}<td></td>{% endfor %}
</tr>
{% for skill_name in skill_rows %}
<tr>
<td>{{ skill_name }}</td>
{% for inj in injectors %}
<td>
{% set val = inj.skills.get(skill_name) %}
{% if val %}
<span class="{% if val.value > 0 %}v-skill{% else %}v-bad{% endif %}">
{{ '+' if val.value > 0 else '' }}{{ val.value }}
</span>
<span class="dur">{{ val.duration }}s</span>
{% else %}<span class="sym-dash"></span>{% endif %}
</td>
{% endfor %}
</tr>
{% endfor %}
<!-- SPECIAL -->
<tr class="row-group">
<td>Special</td>
{% for inj in injectors %}<td></td>{% endfor %}
</tr>
<tr>
<td>Body temp Δ</td>
{% for inj in injectors %}
<td>
{% if inj.body_temp %}
<span class="{% if inj.body_temp > 0 %}v-bad{% else %}v-good{% endif %}">
{{ '+' if inj.body_temp > 0 else '' }}{{ inj.body_temp }}°
</span>
<span class="dur">{{ inj.body_temp_dur }}s</span>
{% else %}<span class="sym-dash"></span>{% endif %}
</td>
{% endfor %}
</tr>
<tr>
<td>Energy Δ /s</td>
{% for inj in injectors %}
<td>
{% if inj.energy %}
<span class="{% if inj.energy > 0 %}v-good{% else %}v-bad{% endif %}">
{{ '+' if inj.energy > 0 else '' }}{{ inj.energy }}
</span>
<span class="dur">{{ inj.energy_dur }}s</span>
{% else %}<span class="sym-dash"></span>{% endif %}
</td>
{% endfor %}
</tr>
<tr>
<td>Hydration Δ /s</td>
{% for inj in injectors %}
<td>
{% if inj.hydration %}
<span class="{% if inj.hydration > 0 %}v-good{% else %}v-bad{% endif %}">
{{ '+' if inj.hydration > 0 else '' }}{{ inj.hydration }}
</span>
<span class="dur">{{ inj.hydration_dur }}s</span>
{% else %}<span class="sym-dash"></span>{% endif %}
</td>
{% endfor %}
</tr>
<!-- SIDE EFFECTS -->
<tr class="row-group">
<td>Side Effects</td>
{% for inj in injectors %}<td></td>{% endfor %}
</tr>
<tr>
<td>Tremors</td>
{% for inj in injectors %}
<td>
{% if inj.tremor %}
<span class="v-bad"></span>
<span class="dur dur-short">{{ inj.tremor_delay }}s delay · {{ inj.tremor_dur }}s</span>
{% else %}<span class="sym-dash"></span>{% endif %}
</td>
{% endfor %}
</tr>
<tr>
<td>Tunnel vision</td>
{% for inj in injectors %}
<td>
{% if inj.tunnel %}
<span class="v-bad"></span>
<span class="dur dur-short">{{ inj.tunnel_delay }}s delay · {{ inj.tunnel_dur }}s</span>
{% else %}<span class="sym-dash"></span>{% endif %}
</td>
{% endfor %}
</tr>
<tr>
<td>Pain</td>
{% for inj in injectors %}
<td>
{% if inj.pain %}
<span class="v-bad"></span>
<span class="dur dur-short">{{ inj.pain_delay }}s delay · {{ inj.pain_dur }}s</span>
{% else %}<span class="sym-dash"></span>{% endif %}
</td>
{% endfor %}
</tr>
</tbody>
</table>
</div><!-- /grid-wrap -->
</div><!-- /page -->
<script>
// Filter columns by tag
document.getElementById("filterBar").addEventListener("click", function(e) {
const btn = e.target.closest(".filter-btn");
if (!btn) return;
document.querySelectorAll(".filter-btn").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
const filter = btn.dataset.filter;
const table = document.getElementById("compTable");
const headers = table.querySelectorAll("thead th");
headers.forEach((th, i) => {
if (i === 0) return; // label col
const tags = (th.dataset.tags || "").split(",");
const show = filter === "all" || tags.includes(filter);
// toggle all cells in column i
table.querySelectorAll("tr").forEach(tr => {
const cells = tr.children;
if (cells[i]) cells[i].style.display = show ? "" : "none";
});
});
});
</script>
</body>
</html>

402
templates/quests.html Normal file
View File

@@ -0,0 +1,402 @@
{# ══════════════════════════════════════════════
MACROS — must come before first use in Jinja2
══════════════════════════════════════════════ #}
{# Flow view: depth-indented vertical chain with connector lines #}
{% macro render_chain(qid, quest_by_id, children, visible, collector_prereqs, depth) %}
{% set q = quest_by_id[qid] %}
{% set visible_kids = [] %}
{% for cid in children.get(qid, []) %}
{% if cid in visible %}{% set _ = visible_kids.append(cid) %}{% endif %}
{% endfor %}
{% if depth > 0 %}<div class="fnode-connector" style="margin-left:{{ depth * 22 + 10 }}px"></div>{% endif %}
<div class="fnode-wrap" style="padding-left:{{ depth * 22 }}px">
<div class="fnode{% if q.done %} done{% endif %}{% if qid in collector_prereqs %} kappa-node{% endif %}"
data-id="{{ qid }}" data-done="{{ '1' if q.done else '0' }}">
<div class="fnode-top">
{% if qid in collector_prereqs %}<span class="kappa-star" title="Collector req"></span>{% endif %}
<span class="fnode-name">{{ q.name }}</span>
{% if q.wiki_link %}<a class="fnode-wiki" href="{{ q.wiki_link }}" target="_blank">wiki</a>{% endif %}
<button class="toggle-btn" onclick="toggle(this)">{{ '✓' if q.done else '○' }}</button>
</div>
</div>
</div>
{% for cid in visible_kids %}
{% set child = quest_by_id[cid] %}
{% if child.trader != q.trader %}
<div class="fnode-connector" style="margin-left:{{ (depth+1)*22 + 10 }}px"></div>
<div class="fnode-wrap" style="padding-left:{{ (depth+1)*22 }}px">
<div class="fnode{% if child.done %} done{% endif %}{% if cid in collector_prereqs %} kappa-node{% endif %}"
data-id="{{ cid }}" data-done="{{ '1' if child.done else '0' }}">
<div class="fnode-top">
{% if cid in collector_prereqs %}<span class="kappa-star"></span>{% endif %}
<span class="fnode-name">{{ child.name }}</span>
{% if child.wiki_link %}<a class="fnode-wiki" href="{{ child.wiki_link }}" target="_blank">wiki</a>{% endif %}
<button class="toggle-btn" onclick="toggle(this)">{{ '✓' if child.done else '○' }}</button>
</div>
<div class="fnode-meta"><span class="cross-badge">{{ child.trader }}</span></div>
</div>
</div>
{% else %}
{{ render_chain(cid, quest_by_id, children, visible, collector_prereqs, depth + 1) }}
{% endif %}
{% endfor %}
{% endmacro %}
{# List view: indented tree with ├── / └── connector lines.
open_stack: list of booleans — True = ancestor has more siblings (draw vert), False = last (draw blank).
is_last: whether this node is the last sibling among its parent's children. #}
{% macro render_list_item(qid, quest_by_id, children, visible, collector_prereqs, open_stack, is_last) %}
{% set q = quest_by_id[qid] %}
{% set visible_kids = [] %}
{% for cid in children.get(qid, []) %}
{% if cid in visible %}{% set _ = visible_kids.append(cid) %}{% endif %}
{% endfor %}
<div class="list-item">
<div class="list-indent">
{% for open in open_stack %}
<div class="list-indent-seg {{ 'vert' if open else 'blank' }}"></div>
{% endfor %}
{% if open_stack %}
<div class="list-indent-seg {{ 'elbow' if is_last else 'tee' }}"></div>
{% endif %}
</div>
<div class="list-row{% if q.done %} done{% endif %}"
data-id="{{ qid }}" data-done="{{ '1' if q.done else '0' }}">
{% if qid in collector_prereqs %}<span class="kappa-star"></span>{% endif %}
<span class="list-name">{{ q.name }}</span>
{% if q.wiki_link %}<a class="list-wiki" href="{{ q.wiki_link }}" target="_blank">wiki</a>{% endif %}
<button class="toggle-btn" onclick="toggle(this)">{{ '✓' if q.done else '○' }}</button>
</div>
</div>
{% set child_stack = open_stack + [not is_last] %}
{% for cid in visible_kids %}
{% set child = quest_by_id[cid] %}
{% set child_last = loop.last %}
{% if child.trader != q.trader %}
<div class="list-item">
<div class="list-indent">
{% for open in child_stack %}
<div class="list-indent-seg {{ 'vert' if open else 'blank' }}"></div>
{% endfor %}
<div class="list-indent-seg {{ 'elbow' if child_last else 'tee' }}"></div>
</div>
<div class="list-row{% if child.done %} done{% endif %}"
data-id="{{ cid }}" data-done="{{ '1' if child.done else '0' }}">
{% if cid in collector_prereqs %}<span class="kappa-star"></span>{% endif %}
<span class="list-name">{{ child.name }}</span>
{% if child.wiki_link %}<a class="list-wiki" href="{{ child.wiki_link }}" target="_blank">wiki</a>{% endif %}
<span class="cross-badge">{{ child.trader }}</span>
<button class="toggle-btn" onclick="toggle(this)">{{ '✓' if child.done else '○' }}</button>
</div>
</div>
{% else %}
{{ render_list_item(cid, quest_by_id, children, visible, collector_prereqs, child_stack, child_last) }}
{% endif %}
{% endfor %}
{% endmacro %}
<!doctype html>
<html>
<head>
<title>OnlyScavs Quest Trees</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
:root {
--bg: #121212;
--panel: #1a1a1a;
--text: #eee;
--muted: #888;
--border: #2a2a2a;
--accent: #9ccfff;
--done-text: #6ec96e;
--done-bg: #1a2a1a;
--kappa: #f0c040;
--line: #333;
}
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
margin: 0;
padding-top: 52px;
background-image: url('/assets/onlyscavs.png');
background-attachment: fixed;
background-size: cover;
background-position: center 65%;
}
body::before {
content: '';
position: fixed;
inset: 0;
background: rgba(14,14,14,0.88);
pointer-events: none;
z-index: 0;
}
.page { max-width: 1100px; margin: 0 auto; padding: 24px 16px; position: relative; z-index: 1; }
.site-nav {
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
display: flex; align-items: center; justify-content: space-between;
padding: 0 24px; height: 52px;
background: rgba(14,14,14,0.92);
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(255,255,255,0.06);
}
.nav-brand { font-size: 0.85rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: var(--accent); text-decoration: none; flex-shrink: 0; }
.nav-links { display: flex; gap: 2px; flex-wrap: wrap; }
.nav-links a { color: #666; text-decoration: none; font-size: 0.8rem; padding: 5px 10px; border-radius: 5px; transition: color 0.15s, background 0.15s; }
.nav-links a:hover { color: var(--text); background: rgba(255,255,255,0.06); }
.nav-links a.active { color: var(--accent); background: rgba(156,207,255,0.08); }
h1 { margin: 0 0 4px; }
/* toolbar */
.toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 18px; }
.filter-btn {
background: var(--panel); border: 1px solid #444; color: var(--text);
border-radius: 6px; padding: 5px 14px; cursor: pointer; font-size: 0.85rem; text-decoration: none;
}
.filter-btn.active { border-color: var(--kappa); color: var(--kappa); }
.sep { color: var(--border); }
.legend { display: flex; gap: 14px; font-size: 0.78rem; color: var(--muted); flex-wrap: wrap; margin-left: auto; }
.legend span { display: flex; align-items: center; gap: 4px; }
/* trader sections */
.trader-section { margin-bottom: 8px; }
.trader-header {
display: flex; align-items: center; gap: 8px; padding: 9px 10px;
background: var(--panel); border: 1px solid var(--border); border-radius: 6px;
cursor: pointer; user-select: none;
}
.trader-header:hover { border-color: #444; }
.trader-name { font-weight: bold; font-size: 0.9rem; flex: 1; }
.trader-counts { font-size: 0.8rem; color: var(--muted); }
.chevron { color: var(--muted); font-size: 0.75rem; transition: transform 0.15s; }
.trader-section.collapsed .chevron { transform: rotate(-90deg); }
.trader-body { padding: 4px 0; }
.trader-section.collapsed .trader-body { display: none; }
/* ── FLOW VIEW ──
Quests rendered as a depth-indented vertical chain.
Parent → children flow top-to-bottom with indent + short vert connector. */
.flow-view .trader-body { padding: 8px 0 4px; }
.fnode-connector {
width: 1px; height: 10px; background: var(--line); flex-shrink: 0;
}
.fnode-wrap { display: flex; flex-direction: column; align-items: flex-start; width: 100%; }
.fnode {
width: calc(100% - 8px); margin: 0 4px; padding: 5px 8px;
border-radius: 5px; background: #141820; border: 1px solid #1e2535;
}
.fnode:hover { background: #1c2030; }
.fnode.done { background: var(--done-bg); border-color: #2a4a2a; }
.fnode.kappa-node { border-color: #5a4a10; }
.fnode-top { display: flex; align-items: center; gap: 5px; min-height: 20px; }
.fnode-name { font-size: 0.83rem; flex: 1; line-height: 1.3; word-break: break-word; }
.fnode.done .fnode-name { text-decoration: line-through; color: var(--done-text); }
.fnode-wiki { color: var(--accent); font-size: 0.7rem; text-decoration: none; flex-shrink: 0; }
.fnode-wiki:hover { text-decoration: underline; }
.fnode-meta { display: flex; align-items: center; gap: 5px; margin-top: 3px; }
.kappa-star { color: var(--kappa); font-size: 0.7rem; flex-shrink: 0; }
.cross-badge {
font-size: 0.65rem; color: var(--muted); font-style: italic;
background: #222; border-radius: 3px; padding: 1px 4px;
}
.toggle-btn {
background: transparent; border: 1px solid #444; color: var(--muted);
border-radius: 3px; padding: 1px 5px; cursor: pointer; font-size: 0.7rem;
flex-shrink: 0; margin-left: auto;
}
.fnode.done .toggle-btn { border-color: #3a6a3a; color: var(--done-text); }
/* ── LIST VIEW ──
Classic file-manager tree: ├── and └── connectors. */
.list-view .trader-body { padding: 4px 0; }
.list-tree { padding: 0; margin: 0; }
.list-item { display: flex; align-items: flex-start; }
.list-indent { display: flex; flex-shrink: 0; }
.list-indent-seg { width: 20px; flex-shrink: 0; position: relative; min-height: 30px; }
.list-indent-seg.vert::before {
content: ""; position: absolute; top: 0; bottom: 0; left: 9px; width: 1px; background: var(--line);
}
.list-indent-seg.tee::before {
content: ""; position: absolute; top: 0; bottom: 0; left: 9px; width: 1px; background: var(--line);
}
.list-indent-seg.tee::after {
content: ""; position: absolute; top: 50%; left: 9px; width: 11px; height: 1px; background: var(--line);
}
.list-indent-seg.elbow::before {
content: ""; position: absolute; top: 0; bottom: 50%; left: 9px; width: 1px; background: var(--line);
}
.list-indent-seg.elbow::after {
content: ""; position: absolute; top: 50%; left: 9px; width: 11px; height: 1px; background: var(--line);
}
/* blank: spacer only, no line */
.list-indent-seg.blank {}
.list-row {
flex: 1; display: flex; align-items: center; gap: 6px;
padding: 4px 6px 4px 2px; border-radius: 4px; margin: 1px 0;
background: transparent; min-height: 30px;
}
.list-row:hover { background: #1a1a1a; }
.list-row.done .list-name { text-decoration: line-through; color: var(--done-text); }
.list-name { font-size: 0.85rem; flex: 1; }
.list-wiki { color: var(--accent); font-size: 0.72rem; text-decoration: none; flex-shrink: 0; }
.list-wiki:hover { text-decoration: underline; }
.list-row .kappa-star { font-size: 0.72rem; }
.list-row .cross-badge { font-size: 0.7rem; }
.list-row .toggle-btn { font-size: 0.72rem; }
.list-row.done .toggle-btn { border-color: #3a6a3a; color: var(--done-text); }
.flow-view.hidden, .list-view.hidden { display: none; }
</style>
</head>
<body>
<div class="page">
<nav class="site-nav">
<a class="nav-brand" href="/">OnlyScavs</a>
<div class="nav-links">
<a href="/keys">Keys</a>
<a href="/collector">Collector</a>
<a href="/quests" class="active">Quests</a>
<a href="/loadout">Loadout</a>
<a href="/meds">Injectors</a>
<a href="/barters">Barters</a>
</div>
</nav>
<h1>Quest Trees</h1>
<div class="toolbar">
<a class="filter-btn {% if not only_collector %}active{% endif %}"
href="/quests?view={{ view }}">All quests</a>
<a class="filter-btn {% if only_collector %}active{% endif %}"
href="/quests?collector=1&view={{ view }}">★ Collector only</a>
<span class="sep">|</span>
<a class="filter-btn {% if view != 'list' %}active{% endif %}"
href="/quests?{% if only_collector %}collector=1&{% endif %}view=flow">Flow</a>
<a class="filter-btn {% if view == 'list' %}active{% endif %}"
href="/quests?{% if only_collector %}collector=1&{% endif %}view=list">List</a>
<span class="sep">|</span>
<button class="filter-btn" type="button" onclick="setAllTradersCollapsed(true)">Collapse all</button>
<button class="filter-btn" type="button" onclick="setAllTradersCollapsed(false)">Expand all</button>
<div class="legend">
<span><span style="color:var(--kappa)"></span> Collector req</span>
<span><span style="color:var(--done-text)"></span> Done</span>
<span><span style="color:var(--muted);font-style:italic">cross</span> Other trader</span>
</div>
</div>
{# ── FLOW VIEW ── #}
<div class="flow-view {% if view == 'list' %}hidden{% endif %}">
{% for trader in traders %}
{% set roots = trader_roots[trader] %}
{% set total_t = namespace(n=0) %}
{% set done_t = namespace(n=0) %}
{% for qid in visible %}
{% if quest_by_id[qid].trader == trader %}
{% set total_t.n = total_t.n + 1 %}
{% if quest_by_id[qid].done %}{% set done_t.n = done_t.n + 1 %}{% endif %}
{% endif %}
{% endfor %}
<div class="trader-section" id="flow-{{ trader | replace(' ', '-') }}">
<div class="trader-header" onclick="toggleTrader(this)">
<span class="trader-name">{{ trader }}</span>
<span class="trader-counts">{{ done_t.n }} / {{ total_t.n }}</span>
<span class="chevron"></span>
</div>
<div class="trader-body">
{% for root_id in roots %}
{{ render_chain(root_id, quest_by_id, children, visible, collector_prereqs, 0) }}
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{# ── LIST VIEW ── #}
<div class="list-view {% if view != 'list' %}hidden{% endif %}">
{% for trader in traders %}
{% set roots = trader_roots[trader] %}
{% set total_t = namespace(n=0) %}
{% set done_t = namespace(n=0) %}
{% for qid in visible %}
{% if quest_by_id[qid].trader == trader %}
{% set total_t.n = total_t.n + 1 %}
{% if quest_by_id[qid].done %}{% set done_t.n = done_t.n + 1 %}{% endif %}
{% endif %}
{% endfor %}
<div class="trader-section" id="list-{{ trader | replace(' ', '-') }}">
<div class="trader-header" onclick="toggleTrader(this)">
<span class="trader-name">{{ trader }}</span>
<span class="trader-counts">{{ done_t.n }} / {{ total_t.n }}</span>
<span class="chevron"></span>
</div>
<div class="trader-body">
<div class="list-tree">
{% for root_id in roots %}
{{ render_list_item(root_id, quest_by_id, children, visible, collector_prereqs, [], loop.last) }}
{% endfor %}
</div>
</div>
</div>
{% endfor %}
</div>
</div>
<script>
function toggleTrader(header) {
header.closest('.trader-section').classList.toggle('collapsed');
persistCollapsed();
}
function setAllTradersCollapsed(collapsed) {
document.querySelectorAll('.trader-section').forEach(s => s.classList.toggle('collapsed', collapsed));
persistCollapsed();
}
const COLLAPSE_KEY = 'quests.collapsedTraders2';
function persistCollapsed() {
const ids = Array.from(document.querySelectorAll('.trader-section.collapsed')).map(s => s.id);
localStorage.setItem(COLLAPSE_KEY, JSON.stringify(ids));
}
(function restoreCollapsed() {
try {
JSON.parse(localStorage.getItem(COLLAPSE_KEY) || '[]').forEach(id => {
const s = document.getElementById(id);
if (s) s.classList.add('collapsed');
});
} catch(e) {}
})();
function toggle(btn) {
const node = btn.closest('[data-id]');
const id = node.dataset.id;
const nowDone = node.dataset.done === '1' ? 0 : 1;
fetch('/collector/toggle', {
method: 'POST',
body: new URLSearchParams({ quest_id: id, done: nowDone })
})
.then(r => r.json())
.then(() => {
document.querySelectorAll(`[data-id="${id}"]`).forEach(n => {
n.dataset.done = nowDone;
const b = n.querySelector('.toggle-btn');
if (!b) return;
if (nowDone) { n.classList.add('done'); b.textContent = '✓'; }
else { n.classList.remove('done'); b.textContent = '○'; }
});
});
}
</script>
</body>
</html>