Files
2026-06-25 21:26:53 +00:00

264 lines
9.3 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// crafts.js
(() => {
const API = 'https://api.tarkov.dev/graphql'; // official GraphQL endpoint (playground at /)
// ---------- GraphQL ----------
// 1) All crafts: structure only (station, required items, rewards).
// NOTE: No variables here—keeps it compatible and avoids "unused variable" errors.
const CRAFTS_Q = `
query AllCrafts {
crafts {
id
duration
station { name } # station.level is not in the public schema we saw—just show the name.
requiredItems {
count
item { id name }
}
rewardItems {
count
item { id name }
}
}
}
`;
// 2) Items by IDs with prices for a specific mode (regular/pve)
// Official examples show avg24hPrice and sellFor { price, source } on items.
const ITEMS_BY_IDS_Q = `
query ItemsByIds($ids: [ID!], $mode: GameMode, $lang: LanguageCode) {
items(ids: $ids, gameMode: $mode, lang: $lang) {
id
name
avg24hPrice
sellFor { price source }
}
}
`;
// ---------- DOM ----------
const modeRadios = Array.from(document.querySelectorAll('input[name="hc-mode"]'));
const metricSel = document.getElementById('hc-metric');
const topNInput = document.getElementById('hc-topn');
const refreshBtn = document.getElementById('hc-refresh');
const updatedEl = document.getElementById('hc-updated');
const statusEl = document.getElementById('hc-status');
const tbody = document.getElementById('hc-tbody');
const chartCanvas = document.getElementById('hc-chart');
const applyFeeChk = document.getElementById('hc-applyfee');
const feePctInput = document.getElementById('hc-fee');
let chart;
let craftsCache = [];
let pricesCache = new Map(); // id -> item pricing record
// ---------- Helpers ----------
const fmt = n => '₽' + new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(Math.round(n || 0));
const nowStr = () => new Date().toLocaleTimeString();
const getMode = () => (modeRadios.find(r => r.checked)?.value) || 'regular';
function fleaPriceFromSellFor(sellFor) {
if (!Array.isArray(sellFor)) return 0;
const flea = sellFor.find(x => x?.source === 'Flea Market');
return Number(flea?.price || 0);
}
function getUnitPrice(itemRec, metric) {
if (!itemRec) return 0;
if (metric === 'avg24hPrice') return Number(itemRec.avg24hPrice || 0);
// default flea: try sellFor(Flea Market), fallback to avg24h
return fleaPriceFromSellFor(itemRec.sellFor) || Number(itemRec.avg24hPrice || 0);
}
function netAfterFee(gross, applyFee, feePct) {
if (!applyFee) return gross;
const pct = Math.max(0, Math.min(100, Number(feePct || 0)));
return gross * (1 - pct / 100);
}
function collectIds(crafts) {
const ids = new Set();
for (const c of crafts) {
for (const ri of (c.requiredItems || [])) ids.add(ri.item?.id);
for (const ro of (c.rewardItems || [])) ids.add(ro.item?.id);
}
ids.delete(undefined);
return Array.from(ids);
}
async function gql(query, variables) {
const resp = await fetch(API, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ query, variables })
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const json = await resp.json();
if (json.errors) throw new Error(json.errors[0]?.message || 'GraphQL error');
return json.data;
}
async function fetchCrafts() {
// Fix: no variables passed—prevents "Variable $lang is never used" error.
const data = await gql(CRAFTS_Q);
return data?.crafts ?? [];
}
async function fetchItemsByIds(ids, mode) {
// batch to avoid giant payloads
const out = new Map();
const BATCH = 80;
for (let i = 0; i < ids.length; i += BATCH) {
const slice = ids.slice(i, i + BATCH);
const data = await gql(ITEMS_BY_IDS_Q, { ids: slice, mode, lang: 'en' });
for (const it of data?.items || []) out.set(it.id, it);
}
return out;
}
function summarize(crafts, metric, applyFee, feePct) {
const rows = [];
for (const c of crafts) {
// Inputs (cost)
const inputs = (c.requiredItems || []).map(ri => {
const rec = pricesCache.get(ri.item?.id);
const unit = getUnitPrice(rec, metric);
return { name: ri.item?.name || 'Unknown', count: ri.count || 0, unit, total: unit * (ri.count || 0) };
});
// Outputs (sell)
const outputs = (c.rewardItems || []).map(ro => {
const rec = pricesCache.get(ro.item?.id);
const unit = getUnitPrice(rec, metric);
return { name: ro.item?.name || 'Unknown', count: ro.count || 0, unit, total: unit * (ro.count || 0) };
});
const cost = inputs.reduce((s, x) => s + x.total, 0);
const sellGross = outputs.reduce((s, x) => s + x.total, 0);
const sellNet = netAfterFee(sellGross, applyFee, feePct);
const profit = sellNet - cost;
// Only station name (no level), because station.level isn't public in the docs/examples we referenced.
const stationLabel = c.station?.name || 'Station';
let craftName = 'Unknown craft';
if (outputs.length === 1) craftName = `${outputs[0].name} ×${outputs[0].count}`;
else if (outputs.length > 1) craftName = `${outputs[0].name} ×${outputs[0].count} (+${outputs.length - 1} other)`;
rows.push({
id: c.id,
craft: craftName,
station: stationLabel,
cost, sellGross, sellNet, profit,
inputs
});
}
rows.sort((a, b) => b.profit - a.profit);
return rows;
}
function renderTable(rows) {
tbody.innerHTML = '';
if (!rows.length) {
tbody.innerHTML = `<tr><td class="pc-muted" colspan="7">No data</td></tr>`;
return;
}
const frag = document.createDocumentFragment();
for (const r of rows) {
const tr = document.createElement('tr');
tr.innerHTML = `
<td data-label="Craft">${r.craft}</td>
<td data-label="Station">${r.station}</td>
<td data-label="Cost" class="right">${fmt(r.cost)}</td>
<td data-label="Sell (gross)" class="right">${fmt(r.sellGross)}</td>
<td data-label="Sell (net)" class="right">${fmt(r.sellNet)}</td>
<td data-label="Profit" class="right ${r.profit >= 0 ? 'good' : 'bad'}">${fmt(r.profit)}</td>
<td data-label="Inputs">${r.inputs.map(i => `${i.name} ×${i.count} (${fmt(i.unit)})`).join(', ')}</td>
`;
frag.appendChild(tr);
}
tbody.appendChild(frag);
}
function renderChart(rows, applyFee) {
const labels = rows.map(r => r.craft);
const costs = rows.map(r => r.cost);
const sells = rows.map(r => applyFee ? r.sellNet : r.sellGross);
const ctx = chartCanvas.getContext('2d');
if (chart) chart.destroy();
chart = new Chart(ctx, {
type: 'bar',
data: {
labels,
datasets: [
{ label: 'Cost to craft', data: costs, backgroundColor: 'rgba(255,99,132,.6)' },
{ label: applyFee ? 'Sell (net)' : 'Sell (gross)', data: sells, backgroundColor: 'rgba(75,192,192,.7)' }
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'top' },
tooltip: {
callbacks: {
afterBody(ctx) {
const i = ctx[0].dataIndex;
const r = rows[i];
return [`Station: ${r.station}`, `Profit: ${fmt(r.profit)}`];
}
}
}
},
scales: {
y: { title: { display: true, text: 'Rubles (₽)' } }
}
}
});
}
async function refresh() {
const mode = getMode(); // 'regular' | 'pve' (items supports gameMode)
const metric = metricSel.value; // 'flea' | 'avg24hPrice'
const topN = Math.max(5, Math.min(200, parseInt(topNInput.value || '30', 10)));
const applyFee = applyFeeChk.checked;
const feePct = parseFloat(feePctInput.value || '0');
statusEl.textContent = 'Loading latest data…';
try {
// Step 1: crafts (structure)
const crafts = await fetchCrafts();
craftsCache = crafts;
// Step 2: price lookup by IDs in selected mode
const ids = collectIds(crafts);
pricesCache = await fetchItemsByIds(ids, mode);
const allRows = summarize(craftsCache, metric, applyFee, feePct);
const rows = allRows.slice(0, topN);
renderTable(rows);
renderChart(rows, applyFee);
updatedEl.textContent = `Data last updated: ${nowStr()}`;
statusEl.textContent = `Showing top ${rows.length} crafts by profit — Mode: ${mode.toUpperCase()}, Metric: ${metric}, Fee: ${applyFee ? (feePct + '%') : 'off'}.`;
} catch (err) {
console.error(err);
tbody.innerHTML = `<tr><td class="pc-muted" colspan="7">Error: ${String(err.message || err)}</td></tr>`;
statusEl.textContent = 'Failed to load data. Try again.';
}
}
// Events & timers
modeRadios.forEach(r => r.addEventListener('change', refresh));
metricSel.addEventListener('change', refresh);
topNInput.addEventListener('input', refresh);
refreshBtn.addEventListener('click', refresh);
applyFeeChk.addEventListener('change', refresh);
feePctInput.addEventListener('input', refresh);
refresh();
setInterval(refresh, 5 * 60 * 1000); // every 5 minutes
})();