Created
August 16, 2026 10:12
-
-
Save jason-murray/32b0ed79e419a281434fcac47ba852ab to your computer and use it in GitHub Desktop.
Trading Paints - Race all paints in a collection
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* ============================================================================ | |
| * Trading Paints — bulk "Race this paint" for an entire collection | |
| * ---------------------------------------------------------------------------- | |
| * Sets every paint in a Trading Paints collection onto your cars in one go, | |
| * instead of opening a tab per paint and clicking "Race this paint" in each. | |
| * | |
| * Made for speed by Jason · https://discord.gg/AjYsz9RM52 | |
| * | |
| * ============================================================================ | |
| * USAGE | |
| * ============================================================================ | |
| * | |
| * 1. Sign in to https://www.tradingpaints.com | |
| * 2. Open the collection you want. Its URL looks like | |
| * https://www.tradingpaints.com/collections/view/<id>/<name> | |
| * 3. Open DevTools (F12 on Windows/Linux, Cmd+Opt+I on macOS) → Console tab | |
| * 4. Paste this whole file in and press Enter | |
| * | |
| * A small panel appears bottom-right with progress, ETA, a live log, and | |
| * Pause / Stop buttons. Drag it by its header if it's in the way. | |
| * | |
| * LEAVE THE TAB OPEN until it finishes. The site rate-limits paint changes to | |
| * roughly one per minute, so budget about a minute per paint. You can | |
| * keep browsing in other tabs; just don't close or navigate this one. | |
| * | |
| * If you do close it, no harm — progress is saved to localStorage. Reopen the | |
| * collection, paste the script again, and it picks up where it left off. | |
| * | |
| * --------------------------------------------------------------------------- | |
| * PREVIEW FIRST (recommended) | |
| * --------------------------------------------------------------------------- | |
| * To see exactly what it would do without changing anything, run this line in | |
| * the console FIRST, then paste the script: | |
| * | |
| * window.TPSET_CFG = { DRY_RUN: true } | |
| * | |
| * It'll scan every paint and list what it would set, update, or skip. | |
| * | |
| * --------------------------------------------------------------------------- | |
| * OPTIONS | |
| * --------------------------------------------------------------------------- | |
| * Same idea — set these before pasting. All optional; see CFG below for the | |
| * full list and defaults. | |
| * | |
| * window.TPSET_CFG = { | |
| * DRY_RUN: false, // preview only, change nothing | |
| * DO_UPDATES: true, // apply painter updates to paints you race | |
| * DO_SETS: true, // set paints you're not racing yet | |
| * DO_SUBMAKES: true, // set every variant of a multi-variant paint | |
| * BASE_MS: 70000, // starting gap between sets — see throttle notes | |
| * } | |
| * | |
| * --------------------------------------------------------------------------- | |
| * CONTROLS | |
| * --------------------------------------------------------------------------- | |
| * From the panel buttons, or the console: | |
| * | |
| * TPSet.pause() // finish the current paint, then hold | |
| * TPSet.resume() | |
| * TPSet.stop() // stop after the current paint | |
| * TPSet.state() // counts so far | |
| * TPSet.reset() // forget saved progress and start the collection over | |
| * | |
| * --------------------------------------------------------------------------- | |
| * NOTES / CAVEATS | |
| * --------------------------------------------------------------------------- | |
| * - Nothing is destructive. Setting a paint is the same action as clicking the | |
| * button yourself, and is undone from your Trading Paints dashboard. | |
| * - It only touches paints in the collection page you run it on. | |
| * - It uses your existing logged-in session. No credentials are read, stored, | |
| * or sent anywhere; every request goes to tradingpaints.com and nowhere else. | |
| * - Paints needing painter approval can't be set by anyone until approved — | |
| * those are reported and skipped. | |
| * - Written against the site as it was in August 2026. It keys off specific | |
| * element ids on the paint page, so a site redesign could break it. If the | |
| * log fills with "no recognised button", that's what happened. | |
| * | |
| * ============================================================================ | |
| * HOW IT WORKS — the four states a paint page can be in | |
| * ============================================================================ | |
| * 1. NOT RACING #setButton present | |
| * -> GET /js/setScheme.php?id=<id>&sub_make=0 | |
| * THROTTLED — roughly one per 60s (see below) | |
| * | |
| * 2. RACING, CURRENT #using_scheme rendered with style="" | |
| * -> nothing to do, skip it | |
| * | |
| * 3. UPDATE PENDING #updateButton present (painter shipped a new version). | |
| * Note #using_scheme is display:none here and there is | |
| * NO #setButton, so this state is easy to misread as | |
| * "not racing" and waste a throttle slot on it. | |
| * -> GET /js/updateScheme.php?id=<id> | |
| * NOT THROTTLED — measured two back-to-back successes, | |
| * and a setScheme immediately afterwards still worked, | |
| * so updates don't touch the set bucket at all. | |
| * Re-running one returns {"status":0, | |
| * "output":"There is nothing to update!"} — a benign no-op. | |
| * | |
| * 4. MULTI-VARIANT No #setButton; instead a "Race this paint" dropdown | |
| * of <div id="sub_<makeId>"> entries, one per car variant | |
| * (e.g. Super Formula SF23 — Toyota / Honda). An entry | |
| * already on your car shows check-square.svg. | |
| * -> one GET /js/setScheme.php?id=<id>&sub_make=<makeId> | |
| * per unchecked variant, each costing its own slot. | |
| * | |
| * --------------------------------------------------------------------------- | |
| * Measured throttle behaviour (probed live, 2026-08-16) | |
| * --------------------------------------------------------------------------- | |
| * - A successful set takes ~5s server-side; refusals come back in ~150ms as | |
| * {"status":0,"output":"You're doing that too quickly. Try again later."} | |
| * - Second set immediately after a success: always refused. | |
| * Refused again at ~49s. Accepted at ~67s. => a ~60s-per-set window. | |
| * - Plain showroom page loads are NOT in that bucket (6 in a row at 1.5s | |
| * apart all returned 200), which is what makes the classify pass free. | |
| * | |
| * Because updates are unthrottled, this runs them all first, then grinds | |
| * through the throttled sets. | |
| * ========================================================================== */ | |
| (() => { | |
| "use strict"; | |
| // ---------------------------------------------------------------- config -- | |
| const CFG = { | |
| // Pacing between successful sets. Starts above the measured ~60s window, | |
| // climbs on pushback, and creeps back down after a clean streak. | |
| BASE_MS: 70_000, | |
| MIN_MS: 62_000, | |
| MAX_MS: 8 * 60_000, | |
| BACKOFF: 1.6, | |
| DECAY: 0.92, | |
| COOL_STREAK: 3, | |
| JITTER: 0.12, // ±12%, so the request pattern isn't perfectly metronomic | |
| DO_UPDATES: true, // apply painter updates to paints you already race | |
| DO_SETS: true, // set paints you're not racing yet | |
| DO_SUBMAKES: true, // set every variant of a multi-variant paint | |
| DRY_RUN: false, // classify and report, but change nothing | |
| CLASSIFY_GAP_MS: 700, // gap between the (unthrottled) page loads | |
| UPDATE_GAP_MS: 2_000, // gap between updates — unthrottled, but be polite | |
| MAX_ATTEMPTS: 12, // per set, before giving up on it | |
| STORAGE_KEY: "tp_set_all_v2", | |
| }; | |
| // Overrides from window.TPSET_CFG — see the USAGE block above. | |
| Object.assign(CFG, window.TPSET_CFG || {}); | |
| const ORIGIN = "https://www.tradingpaints.com"; | |
| const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); | |
| const jitter = (ms) => | |
| Math.round(ms * (1 + (Math.random() * 2 - 1) * CFG.JITTER)); | |
| const fmt = (ms) => { | |
| const s = Math.max(0, Math.round(ms / 1000)); | |
| return s < 60 | |
| ? `${s}s` | |
| : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s`; | |
| }; | |
| if (window.TPSet?.running) { | |
| console.warn("[TPSet] already running — call TPSet.stop() first."); | |
| return; | |
| } | |
| // ------------------------------------------------------ collect the work -- | |
| // Each paint card carries javascript:favorite('<schemeId>') plus a link to | |
| // /showroom/view/<id>/<slug>. The slug is required — /showroom/view/<id>/ on | |
| // its own 404s — so pair them up here rather than rebuilding URLs later. | |
| function scrapePaints() { | |
| const seen = new Map(); | |
| document.querySelectorAll('a[href*="favorite("]').forEach((a) => { | |
| const id = a.getAttribute("href").match(/favorite\('(\d+)'\)/)?.[1]; | |
| if (!id || seen.has(id)) return; | |
| const link = document.querySelector(`a[href*="/showroom/view/${id}/"]`); | |
| const slug = link?.href.match(/\/showroom\/view\/\d+\/([^/?#]+)/)?.[1]; | |
| seen.set(id, { | |
| id, | |
| url: link?.href || null, | |
| name: slug ? decodeURIComponent(slug).replace(/-/g, " ") : `#${id}`, | |
| }); | |
| }); | |
| return [...seen.values()]; | |
| } | |
| // ------------------------------------------------------------ persistence -- | |
| const store = { | |
| load() { | |
| try { | |
| return JSON.parse(localStorage.getItem(CFG.STORAGE_KEY)) || {}; | |
| } catch { | |
| return {}; | |
| } | |
| }, | |
| save(v) { | |
| try { | |
| localStorage.setItem(CFG.STORAGE_KEY, JSON.stringify(v)); | |
| } catch {} | |
| }, | |
| clear() { | |
| try { | |
| localStorage.removeItem(CFG.STORAGE_KEY); | |
| } catch {} | |
| }, | |
| }; | |
| // --------------------------------------------------------- classification -- | |
| // Fetch a paint page and work out which of the four states it's in. | |
| // These page loads sit outside the set throttle, so this pass is free. | |
| async function classify(paint) { | |
| if (!paint.url) return { state: "unknown", why: "no url on the card" }; | |
| let html; | |
| try { | |
| const res = await fetch(paint.url, { credentials: "include" }); | |
| if (!res.ok) return { state: "unknown", why: `HTTP ${res.status}` }; | |
| html = await res.text(); | |
| } catch (e) { | |
| return { state: "unknown", why: e.message }; | |
| } | |
| // Update takes precedence: it's cheap, unthrottled, and leaves the paint | |
| // current. A multi-variant paint can also have an update pending. | |
| if (/id="updateButton"/.test(html)) return { state: "update" }; | |
| // Multi-variant dropdown. NB collections render as id="coll_<n>", not sub_. | |
| const subs = [ | |
| ...html.matchAll(/<div id="sub_(\d+)">([\s\S]{0,500}?)<\/a>/g), | |
| ].map((m) => ({ | |
| mid: m[1], | |
| checked: /check-square\.svg/.test(m[2]), | |
| name: | |
| m[2].match(/selectSubMake\('\d+','\d+','([^']+)'\)/)?.[1] || | |
| `make ${m[1]}`, | |
| })); | |
| if (subs.length) return { state: "subs", subs }; | |
| if (/id="setButton"/.test(html)) return { state: "set" }; | |
| if (/id="using_scheme" style=""/.test(html)) return { state: "racing" }; | |
| return { state: "unknown", why: "no recognised button" }; | |
| } | |
| // ------------------------------------------------------------- endpoints -- | |
| // -> {kind:'ok'|'throttled'|'noop'|'error', msg, fatal?} | |
| async function call(url) { | |
| let res; | |
| try { | |
| res = await fetch(`${url}&_=${Date.now()}`, { | |
| credentials: "include", | |
| headers: { "X-Requested-With": "XMLHttpRequest" }, | |
| }); | |
| } catch (e) { | |
| return { kind: "error", msg: `network: ${e.message}` }; | |
| } | |
| if (res.status === 429 || res.status === 503) | |
| return { kind: "throttled", msg: `HTTP ${res.status}` }; | |
| const text = await res.text(); | |
| let data; | |
| try { | |
| data = JSON.parse(text); | |
| } catch { | |
| // A login redirect or an HTML error page lands here. | |
| if (text.length > 2000) | |
| return { | |
| kind: "error", | |
| msg: "got an HTML page — session expired?", | |
| fatal: true, | |
| }; | |
| return { kind: "error", msg: `unparseable: ${text.slice(0, 80)}` }; | |
| } | |
| if (String(data.status) === "1") { | |
| const o = data.output || {}; | |
| return { kind: "ok", msg: o.make_name || "done" }; | |
| } | |
| const out = | |
| typeof data.output === "string" | |
| ? data.output | |
| : JSON.stringify(data.output); | |
| if (/too quickly|slow down|try again later|rate limit/i.test(out)) | |
| return { kind: "throttled", msg: out }; | |
| if (/nothing to update/i.test(out)) return { kind: "noop", msg: out }; | |
| return { kind: "error", msg: out || "unknown failure" }; | |
| } | |
| const setScheme = (id, subMake = 0) => | |
| call(`${ORIGIN}/js/setScheme.php?id=${id}&sub_make=${subMake}`); | |
| const updateScheme = (id) => call(`${ORIGIN}/js/updateScheme.php?id=${id}`); | |
| // ------------------------------------------------------------------- HUD -- | |
| const hud = (() => { | |
| document.getElementById("tpset-hud")?.remove(); | |
| const el = document.createElement("div"); | |
| el.id = "tpset-hud"; | |
| el.innerHTML = ` | |
| <style> | |
| #tpset-hud{position:fixed;z-index:2147483647;right:16px;bottom:16px;width:350px; | |
| font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace;color:#eaeaea; | |
| background:#14171c;border:1px solid #333;border-radius:10px; | |
| box-shadow:0 12px 40px rgba(0,0,0,.5);overflow:hidden} | |
| #tpset-hud .hd{display:flex;align-items:center;gap:6px;padding:9px 11px;background:#1d222a; | |
| cursor:move;user-select:none} | |
| #tpset-hud .hd b{flex:1;font-size:11px;text-transform:uppercase;letter-spacing:.05em} | |
| #tpset-hud .bd{padding:11px} | |
| #tpset-hud .bar{height:6px;background:#2a2f38;border-radius:3px;overflow:hidden;margin:8px 0} | |
| #tpset-hud .bar i{display:block;height:100%;width:0;background:#3d8bff;transition:width .3s} | |
| #tpset-hud .row{display:flex;justify-content:space-between;gap:8px} | |
| #tpset-hud .dim{color:#8a93a0} | |
| #tpset-hud .now{margin:6px 0 2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} | |
| #tpset-hud .log{margin-top:9px;max-height:160px;overflow:auto;border-top:1px solid #2a2f38;padding-top:7px} | |
| #tpset-hud .log div{white-space:nowrap;overflow:hidden;text-overflow:ellipsis} | |
| #tpset-hud button{background:#2a2f38;color:#eaeaea;border:1px solid #3a414c;border-radius:5px; | |
| padding:3px 9px;font:inherit;font-size:11px;cursor:pointer} | |
| #tpset-hud button:hover{background:#353c47} | |
| #tpset-hud .ft{display:flex;align-items:center;justify-content:space-between;gap:8px; | |
| padding:7px 11px;background:#1a1e25;border-top:1px solid #2a2f38;font-size:10.5px;color:#8a93a0} | |
| #tpset-hud .ft a{color:#7aa7ff;text-decoration:none} | |
| #tpset-hud .ft a:hover{text-decoration:underline} | |
| #tpset-hud .ft .acc{color:#ffc44d} | |
| .tps-ok{color:#5ddc7f}.tps-upd{color:#7aa7ff}.tps-wait{color:#ffc44d} | |
| .tps-err{color:#ff7a7a}.tps-skip{color:#8a93a0} | |
| </style> | |
| <div class="hd"><b>Trading Paints · bulk set</b> | |
| <button data-a="pause">Pause</button><button data-a="stop">Stop</button></div> | |
| <div class="bd"> | |
| <div class="row"><span id="tps-count" class="dim">—</span><span id="tps-eta" class="dim"></span></div> | |
| <div class="bar"><i id="tps-bar"></i></div> | |
| <div class="now" id="tps-now">starting…</div> | |
| <div class="log" id="tps-log"></div> | |
| </div> | |
| <div class="ft"> | |
| <span>Made for <span class="acc">speed</span> by Jason</span> | |
| <a href="https://discord.gg/AjYsz9RM52" target="_blank" rel="noopener noreferrer">Discord →</a> | |
| </div>`; | |
| document.body.appendChild(el); | |
| const hd = el.querySelector(".hd"); | |
| let ox = 0, | |
| oy = 0, | |
| dragging = false; | |
| hd.addEventListener("mousedown", (e) => { | |
| if (e.target.tagName === "BUTTON") return; | |
| const r = el.getBoundingClientRect(); | |
| ox = e.clientX - r.left; | |
| oy = e.clientY - r.top; | |
| dragging = true; | |
| e.preventDefault(); | |
| }); | |
| addEventListener("mousemove", (e) => { | |
| if (!dragging) return; | |
| Object.assign(el.style, { | |
| left: `${e.clientX - ox}px`, | |
| top: `${e.clientY - oy}px`, | |
| right: "auto", | |
| bottom: "auto", | |
| }); | |
| }); | |
| addEventListener("mouseup", () => (dragging = false)); | |
| const $ = (s) => el.querySelector(s); | |
| return { | |
| onAction(fn) { | |
| hd.addEventListener( | |
| "click", | |
| (e) => e.target.dataset?.a && fn(e.target.dataset.a, e.target), | |
| ); | |
| }, | |
| progress(done, total) { | |
| $("#tps-count").textContent = `${done} / ${total}`; | |
| $("#tps-bar").style.width = `${total ? (done / total) * 100 : 0}%`; | |
| }, | |
| eta: (t) => ($("#tps-eta").textContent = t), | |
| now: (t) => ($("#tps-now").textContent = t), | |
| log(msg, cls = "") { | |
| const d = document.createElement("div"); | |
| d.className = cls; | |
| d.textContent = msg; | |
| const box = $("#tps-log"); | |
| box.prepend(d); | |
| while (box.children.length > 200) box.lastChild.remove(); | |
| }, | |
| }; | |
| })(); | |
| // ------------------------------------------------------------------ main -- | |
| const S = { | |
| running: true, | |
| paused: false, | |
| stopped: false, | |
| gap: CFG.BASE_MS, | |
| streak: 0, | |
| done: store.load(), | |
| stats: { set: 0, updated: 0, skipped: 0, failed: 0, throttles: 0 }, | |
| }; | |
| window.TPSet = S; | |
| const say = (m, c) => { | |
| hud.log(m, c); | |
| console.log(`[TPSet] ${m}`); | |
| }; | |
| S.pause = () => { | |
| S.paused = true; | |
| say("paused", "tps-wait"); | |
| }; | |
| S.resume = () => { | |
| S.paused = false; | |
| say("resumed"); | |
| }; | |
| S.stop = () => { | |
| S.stopped = true; | |
| S.paused = false; | |
| say("stopping…", "tps-err"); | |
| }; | |
| S.reset = () => { | |
| store.clear(); | |
| S.done = {}; | |
| say("saved progress cleared"); | |
| }; | |
| S.state = () => ({ | |
| ...S.stats, | |
| gapMs: S.gap, | |
| remembered: Object.keys(S.done).length, | |
| }); | |
| hud.onAction((a, btn) => { | |
| if (a === "stop") return S.stop(); | |
| S.paused ? S.resume() : S.pause(); | |
| btn.textContent = S.paused ? "Resume" : "Pause"; | |
| }); | |
| // Interruptible wait: wakes early on stop, and stops counting down while paused. | |
| async function waitGap(ms, label) { | |
| let until = Date.now() + ms; | |
| while (Date.now() < until) { | |
| if (S.stopped) return; | |
| if (S.paused) { | |
| until += 500; | |
| await sleep(500); | |
| continue; | |
| } | |
| hud.now(`${label} — next in ${fmt(until - Date.now())}`); | |
| await sleep(Math.min(1000, until - Date.now())); | |
| } | |
| } | |
| (async () => { | |
| const paints = scrapePaints(); | |
| if (!paints.length) { | |
| say("no paints found — are you on a collection page?", "tps-err"); | |
| hud.now("nothing to do"); | |
| S.running = false; | |
| return; | |
| } | |
| const queue = paints.filter((p) => !S.done[p.id]); | |
| const resumed = paints.length - queue.length; | |
| say( | |
| `${paints.length} paints found${resumed ? `, ${resumed} done in a previous run` : ""}`, | |
| ); | |
| // ---- pass 1: classify everything (free — not throttled) ---------------- | |
| const updates = []; | |
| const sets = []; // {paint, subMake, label} | |
| for (let i = 0; i < queue.length && !S.stopped; i++) { | |
| while (S.paused) await sleep(400); | |
| const p = queue[i]; | |
| hud.now(`checking ${i + 1}/${queue.length}: ${p.name}`); | |
| const c = await classify(p); | |
| if (c.state === "racing") { | |
| S.done[p.id] = "racing"; | |
| S.stats.skipped++; | |
| say(`skip · ${p.name} — already current`, "tps-skip"); | |
| } else if (c.state === "update") { | |
| updates.push(p); | |
| } else if (c.state === "subs") { | |
| const pending = c.subs.filter((s) => !s.checked); | |
| c.subs | |
| .filter((s) => s.checked) | |
| .forEach((s) => say(`skip · ${p.name} [${s.name}]`, "tps-skip")); | |
| if (!pending.length) S.done[p.id] = "racing"; | |
| if (CFG.DO_SUBMAKES) { | |
| pending.forEach((s) => | |
| sets.push({ | |
| paint: p, | |
| subMake: s.mid, | |
| label: `${p.name} [${s.name}]`, | |
| }), | |
| ); | |
| } else { | |
| say( | |
| `skip · ${p.name} — ${pending.length} variants (DO_SUBMAKES off)`, | |
| "tps-skip", | |
| ); | |
| } | |
| } else if (c.state === "set") { | |
| sets.push({ paint: p, subMake: 0, label: p.name }); | |
| } else { | |
| say(`? · ${p.name} — ${c.why}; will try a plain set`, "tps-wait"); | |
| sets.push({ paint: p, subMake: 0, label: p.name }); | |
| } | |
| await sleep(jitter(CFG.CLASSIFY_GAP_MS)); | |
| } | |
| store.save(S.done); | |
| if (!CFG.DO_UPDATES) updates.length = 0; | |
| if (!CFG.DO_SETS) sets.length = 0; | |
| const total = updates.length + sets.length; | |
| let processed = 0; | |
| hud.progress(0, total); | |
| say( | |
| `${updates.length} to update (fast) · ${sets.length} to set · ETA ~${fmt(sets.length * S.gap)}`, | |
| ); | |
| if (CFG.DRY_RUN) { | |
| updates.forEach((p) => say(`would update · ${p.name}`, "tps-upd")); | |
| sets.forEach((t) => say(`would set · ${t.label}`, "tps-wait")); | |
| hud.now( | |
| `dry run — ${updates.length} updates, ${sets.length} sets, ${S.stats.skipped} already current`, | |
| ); | |
| S.running = false; | |
| return; | |
| } | |
| // ---- pass 2: updates. Unthrottled, so blow through them ---------------- | |
| for (const p of updates) { | |
| if (S.stopped) break; | |
| while (S.paused) await sleep(400); | |
| hud.now(`updating: ${p.name}`); | |
| const r = await updateScheme(p.id); | |
| if (r.kind === "ok" || r.kind === "noop") { | |
| S.done[p.id] = "updated"; | |
| store.save(S.done); | |
| if (r.kind === "ok") { | |
| S.stats.updated++; | |
| say(`upd · ${p.name} → ${r.msg}`, "tps-upd"); | |
| } else { | |
| S.stats.skipped++; | |
| say(`skip · ${p.name} — nothing to update`, "tps-skip"); | |
| } | |
| } else if (r.kind === "throttled") { | |
| // Not observed in testing, but handle it rather than lose the paint. | |
| S.stats.throttles++; | |
| say(`wait · update throttled on ${p.name}`, "tps-wait"); | |
| await waitGap(jitter(S.gap), `backing off · ${p.name}`); | |
| const retry = await updateScheme(p.id); | |
| if (retry.kind === "ok" || retry.kind === "noop") { | |
| S.done[p.id] = "updated"; | |
| store.save(S.done); | |
| S.stats.updated++; | |
| say(`upd · ${p.name} → ${retry.msg}`, "tps-upd"); | |
| } else { | |
| S.stats.failed++; | |
| say(`fail · ${p.name}: ${retry.msg}`, "tps-err"); | |
| } | |
| } else { | |
| if (r.fatal) { | |
| say(r.msg, "tps-err"); | |
| S.stop(); | |
| break; | |
| } | |
| S.stats.failed++; | |
| say(`fail · ${p.name}: ${r.msg}`, "tps-err"); | |
| } | |
| hud.progress(++processed, total); | |
| if (!S.stopped) await sleep(jitter(CFG.UPDATE_GAP_MS)); | |
| } | |
| // ---- pass 3: sets. One per throttle window ----------------------------- | |
| hud.eta(sets.length ? `~${fmt(sets.length * S.gap)} left` : ""); | |
| for (let i = 0; i < sets.length && !S.stopped; i++) { | |
| const t = sets[i]; | |
| for (let attempt = 1; !S.stopped; attempt++) { | |
| while (S.paused) await sleep(400); | |
| hud.now(`setting: ${t.label}`); | |
| const r = await setScheme(t.paint.id, t.subMake); | |
| if (r.kind === "ok") { | |
| S.done[t.paint.id] = "set"; | |
| store.save(S.done); | |
| S.stats.set++; | |
| say(`set · ${t.label} → ${r.msg}`, "tps-ok"); | |
| if (++S.streak >= CFG.COOL_STREAK) { | |
| S.streak = 0; | |
| S.gap = Math.max(CFG.MIN_MS, Math.round(S.gap * CFG.DECAY)); | |
| } | |
| break; | |
| } | |
| if (r.kind === "throttled") { | |
| S.stats.throttles++; | |
| S.streak = 0; | |
| S.gap = Math.min(CFG.MAX_MS, Math.round(S.gap * CFG.BACKOFF)); | |
| if (attempt >= CFG.MAX_ATTEMPTS) { | |
| S.stats.failed++; | |
| say( | |
| `give up · ${t.label} (still throttled after ${attempt} tries)`, | |
| "tps-err", | |
| ); | |
| break; | |
| } | |
| say( | |
| `wait · throttled on ${t.label}, gap → ${fmt(S.gap)}`, | |
| "tps-wait", | |
| ); | |
| await waitGap(jitter(S.gap), `backing off · ${t.label}`); | |
| continue; | |
| } | |
| if (r.fatal) { | |
| say(r.msg, "tps-err"); | |
| S.stop(); | |
| break; | |
| } | |
| S.stats.failed++; | |
| say(`fail · ${t.label}: ${r.msg}`, "tps-err"); | |
| break; | |
| } | |
| hud.progress(++processed, total); | |
| const left = sets.length - i - 1; | |
| hud.eta(left ? `~${fmt(left * S.gap)} left` : ""); | |
| if (left && !S.stopped) await waitGap(jitter(S.gap), `${left} left`); | |
| } | |
| S.running = false; | |
| hud.now(S.stopped ? "stopped" : "finished"); | |
| say( | |
| `done — ${S.stats.set} set, ${S.stats.updated} updated, ${S.stats.skipped} skipped, ` + | |
| `${S.stats.failed} failed, ${S.stats.throttles} throttles`, | |
| S.stats.failed ? "tps-err" : "tps-ok", | |
| ); | |
| })(); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment