From 5941b1d2360852d2234be98e52fee3ee8bd96b19 Mon Sep 17 00:00:00 2001 From: GaboGG Date: Tue, 4 Aug 2026 15:53:25 -0400 Subject: [PATCH] v0.17.0 - jpx round-chaining bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New src/jpx-bridge.js: re-triggers jpx auto-battle (M key) after each round completes, chaining single-round auto-battler into continuous play - Config: autoChainJpx (default on), chainMinHP 0.25, chainMaxRounds 0 - Safety: stops below chainMinHP, at maxRounds, or when jpx absent - Settings panel: jpx Bridge section - API: HV.chain(on), HV.chainState() - Works by dispatching synthetic 'm' keydown (jpx reads e.key, not isTrusted) - Never re-implements battle logic — jpx executes, we bridge rounds --- scripts/build.sh | 1 + scripts/hv-unified.user.js | 140 ++++++++++++++++++++++++++++++++++++- scripts/latest.user.js | 140 ++++++++++++++++++++++++++++++++++++- src/config.js | 7 +- src/header.user.js | 2 +- src/init.js | 15 ++++ src/jpx-bridge.js | 107 ++++++++++++++++++++++++++++ src/public-api.js | 3 + src/settings-panel.js | 5 ++ 9 files changed, 414 insertions(+), 6 deletions(-) create mode 100644 src/jpx-bridge.js diff --git a/scripts/build.sh b/scripts/build.sh index 81ca7f4..c925930 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -21,6 +21,7 @@ FILES=( items.js knowledge-base.js strategy-engine.js + jpx-bridge.js action-executor.js hover-system.js keybindings.js diff --git a/scripts/hv-unified.user.js b/scripts/hv-unified.user.js index 026db78..9e3d8db 100644 --- a/scripts/hv-unified.user.js +++ b/scripts/hv-unified.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name HV Unified // @namespace hvunified -// @version 0.16.3 +// @version 0.17.0 // @description HV Unified — Bridge & Strategy/Gear Advisor for HentaiVerse (complements Monsterbation & HVUtils) // @author GaboGG + Hermes // @match *://*.hentaiverse.org/* @@ -18,7 +18,7 @@ // CONFIG — default settings // ═══════════════════════════════════════════════════════════════════════ -const VERSION = '0.16.3'; +const VERSION = '0.17.0'; const CFG = { // — Battle strategy advisory @@ -45,6 +45,11 @@ const CFG = { showGuidance: true, showEquipAdvice: true, autoDifficulty: true, + + // — jpx integration (bridge) + autoChainJpx: true, // Re-trigger jpx auto-battle (M) after each round + chainMinHP: 0.25, // Pause chaining below this HP + chainMaxRounds: 0, // 0 = unlimited; else stop after N rounds }; @@ -1540,6 +1545,114 @@ function strategyVeteran() { return null; } +// ═══════════════════════════════════════════════════════════════════════ +// JPX BRIDGE — chain jpx's single-round auto-battler into continuous play. +// jpx (by design) runs ONE round per M press. We re-trigger it after each +// round completes, with safety guards. We NEVER re-implement battle logic — +// jpx executes; we bridge rounds. +// ═══════════════════════════════════════════════════════════════════════ + +let jpxChainStarted = false; // chain engaged for current battle +let jpxChainRounds = 0; // rounds chained this battle +let jpxChainTimer = null; + +function jpxPresent() { + return !!(document.getElementById('ctrl-widget') || + document.getElementById('homosex') === null && document.querySelector('.ctrl-widget, #ctrl-widget')); +} + +// Trigger jpx's auto-battle toggle (equivalent to pressing M). +// jpx listens for keydown on document (capture phase) and maps key 'm' to +// toggleActive via userKeybinds. A synthetic KeyboardEvent works because +// jpx reads e.key, not e.isTrusted. +function jpxTriggerAuto() { + try { + const evt = new KeyboardEvent('keydown', { + key: 'm', + code: 'KeyM', + keyCode: 77, + which: 77, + bubbles: true, + cancelable: true, + }); + document.dispatchEvent(evt); + return true; + } catch (e) { + console.warn('[HV] jpxTriggerAuto failed:', e.message); + return false; + } +} + +// Is a round currently active (monsters present)? +function jpxRoundActive() { + const monsters = document.querySelectorAll('.btm1'); + const btcp = document.getElementById('btcp'); + // Round is active when monsters exist and the continue button is NOT visible + return monsters.length > 0 && !(btcp && btcp.style.visibility !== 'hidden' && btcp.style.display !== 'none'); +} + +// Check if the continue/next-round button is up (round ended) +function jpxRoundEnded() { + const btcp = document.getElementById('btcp'); + if (btcp) { + const vis = btcp.style.visibility; + const disp = btcp.style.display; + // jpx hides it (visibility:hidden) after clicking; visible = round ended + if (vis !== 'hidden' && disp !== 'none') return true; + } + // Fallback: finish-battle button present means battle over (not round) + if (document.querySelector('img[src$="finishbattle.png"]')) return false; + return false; +} + +// Try to start the chain (called after battle initializes) +function jpxChainStart() { + if (!CFG.autoChainJpx) return; + if (jpxChainStarted) return; + if (!jpxPresent()) return; + + jpxChainStarted = true; + jpxChainRounds = 0; + // Start the first auto round (equivalent to pressing M once) + setTimeout(jpxTriggerAuto, 800); + console.log('%c[HV] ⛓ jpx auto-chain engaged (re-triggering M each round)', 'color:#0f0'); +} + +// Called periodically while in battle — re-trigger when a round ends +function jpxChainTick() { + if (!CFG.autoChainJpx) return; + if (!jpxChainStarted) return; + if (!jpxPresent()) return; + + // Stop conditions + if (CFG.chainMaxRounds > 0 && jpxChainRounds >= CFG.chainMaxRounds) { + jpxChainStop('max rounds reached'); + return; + } + if (STATE.hp !== undefined && STATE.hp < CFG.chainMinHP) { + jpxChainStop('HP below ' + Math.round(CFG.chainMinHP * 100) + '%'); + return; + } + + if (jpxRoundEnded()) { + // Round ended, continue button visible → re-trigger auto for next round + jpxChainRounds++; + jpxTriggerAuto(); + console.log(`%c[HV] ⛓ chaining round ${jpxChainRounds}${CFG.chainMaxRounds ? '/' + CFG.chainMaxRounds : ''}`, 'color:#0f0'); + } +} + +function jpxChainStop(reason) { + if (!jpxChainStarted) return; + jpxChainStarted = false; + console.log('%c[HV] ⛓ jpx auto-chain stopped: ' + reason, 'color:#f80'); +} + +function jpxChainReset() { + jpxChainStarted = false; + jpxChainRounds = 0; +} + // ═══════════════════════════════════════════════════════════════════════ // ACTION EXECUTOR — carry out recommended actions // ═══════════════════════════════════════════════════════════════════════ @@ -1772,6 +1885,11 @@ function openSettings() { ${mkTog('Advisor panel', 'showGuidance')} ${mkTog('Equip KEEP/SELL tags', 'showEquipAdvice')} ${mkTog('Config button', 'cfgButton')} +
+ jpx Bridge + ${mkTog('Auto-chain jpx rounds (re-press M)', 'autoChainJpx')} + ${mkNum('Min HP% to keep chaining', 'chainMinHP', 0.05)} + ${mkNum('Max chained rounds (0=∞)', 'chainMaxRounds', 1)}
Changes apply immediately. Press , to open settings.
`; @@ -3618,6 +3736,9 @@ window.HV = { tier: () => STATE.tier, stats: () => JSON.parse(localStorage[SP + 'stats'] || '{"battles":0,"credits":0,"drops":0}'), settings: () => toggleSettings(), + // jpx bridge control + chain: (on) => { CFG.autoChainJpx = !!on; saveConfig(); if (on) { jpxChainStart(); if (!jpxChainTimer) jpxChainTimer = setInterval(jpxChainTick, 1500); } else { jpxChainStop('manual'); } return 'jpx chain: ' + CFG.autoChainJpx; }, + chainState: () => ({ active: jpxChainStarted, rounds: jpxChainRounds, jpx: jpxPresent(), maxRounds: CFG.chainMaxRounds, minHP: CFG.chainMinHP }), advice: () => { const s = detectFightingStyle(); return { style: s, attrs: getAttrAdvice(s), spells: getSpellAdvice(), difficulty: getDifficultyAdvice() }; @@ -3748,6 +3869,21 @@ function initializeBattle() { vobs.observe(vitals, { childList: true, subtree: true, attributes: true }); } + // jpx bridge: engage the round-chaining loop + if (CFG.autoChainJpx) { + jpxChainStart(); + if (!jpxChainTimer) { + jpxChainTimer = setInterval(jpxChainTick, 1500); + } + // Watch the continue button for round-end → next round trigger + const btcp = document.getElementById('btcp'); + if (btcp && !btcp.dataset.hvObserved) { + btcp.dataset.hvObserved = '1'; + const bobs = new MutationObserver(() => jpxChainTick()); + bobs.observe(btcp, { attributes: true, attributeFilter: ['style', 'class', 'visibility', 'display'] }); + } + } + // Auto-scrape gear data on character/armory pages const url = window.location.href || ''; if (url.includes('ss=eq') || url.includes('ss=ch') || url.includes('ss=am')) { diff --git a/scripts/latest.user.js b/scripts/latest.user.js index 026db78..9e3d8db 100644 --- a/scripts/latest.user.js +++ b/scripts/latest.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name HV Unified // @namespace hvunified -// @version 0.16.3 +// @version 0.17.0 // @description HV Unified — Bridge & Strategy/Gear Advisor for HentaiVerse (complements Monsterbation & HVUtils) // @author GaboGG + Hermes // @match *://*.hentaiverse.org/* @@ -18,7 +18,7 @@ // CONFIG — default settings // ═══════════════════════════════════════════════════════════════════════ -const VERSION = '0.16.3'; +const VERSION = '0.17.0'; const CFG = { // — Battle strategy advisory @@ -45,6 +45,11 @@ const CFG = { showGuidance: true, showEquipAdvice: true, autoDifficulty: true, + + // — jpx integration (bridge) + autoChainJpx: true, // Re-trigger jpx auto-battle (M) after each round + chainMinHP: 0.25, // Pause chaining below this HP + chainMaxRounds: 0, // 0 = unlimited; else stop after N rounds }; @@ -1540,6 +1545,114 @@ function strategyVeteran() { return null; } +// ═══════════════════════════════════════════════════════════════════════ +// JPX BRIDGE — chain jpx's single-round auto-battler into continuous play. +// jpx (by design) runs ONE round per M press. We re-trigger it after each +// round completes, with safety guards. We NEVER re-implement battle logic — +// jpx executes; we bridge rounds. +// ═══════════════════════════════════════════════════════════════════════ + +let jpxChainStarted = false; // chain engaged for current battle +let jpxChainRounds = 0; // rounds chained this battle +let jpxChainTimer = null; + +function jpxPresent() { + return !!(document.getElementById('ctrl-widget') || + document.getElementById('homosex') === null && document.querySelector('.ctrl-widget, #ctrl-widget')); +} + +// Trigger jpx's auto-battle toggle (equivalent to pressing M). +// jpx listens for keydown on document (capture phase) and maps key 'm' to +// toggleActive via userKeybinds. A synthetic KeyboardEvent works because +// jpx reads e.key, not e.isTrusted. +function jpxTriggerAuto() { + try { + const evt = new KeyboardEvent('keydown', { + key: 'm', + code: 'KeyM', + keyCode: 77, + which: 77, + bubbles: true, + cancelable: true, + }); + document.dispatchEvent(evt); + return true; + } catch (e) { + console.warn('[HV] jpxTriggerAuto failed:', e.message); + return false; + } +} + +// Is a round currently active (monsters present)? +function jpxRoundActive() { + const monsters = document.querySelectorAll('.btm1'); + const btcp = document.getElementById('btcp'); + // Round is active when monsters exist and the continue button is NOT visible + return monsters.length > 0 && !(btcp && btcp.style.visibility !== 'hidden' && btcp.style.display !== 'none'); +} + +// Check if the continue/next-round button is up (round ended) +function jpxRoundEnded() { + const btcp = document.getElementById('btcp'); + if (btcp) { + const vis = btcp.style.visibility; + const disp = btcp.style.display; + // jpx hides it (visibility:hidden) after clicking; visible = round ended + if (vis !== 'hidden' && disp !== 'none') return true; + } + // Fallback: finish-battle button present means battle over (not round) + if (document.querySelector('img[src$="finishbattle.png"]')) return false; + return false; +} + +// Try to start the chain (called after battle initializes) +function jpxChainStart() { + if (!CFG.autoChainJpx) return; + if (jpxChainStarted) return; + if (!jpxPresent()) return; + + jpxChainStarted = true; + jpxChainRounds = 0; + // Start the first auto round (equivalent to pressing M once) + setTimeout(jpxTriggerAuto, 800); + console.log('%c[HV] ⛓ jpx auto-chain engaged (re-triggering M each round)', 'color:#0f0'); +} + +// Called periodically while in battle — re-trigger when a round ends +function jpxChainTick() { + if (!CFG.autoChainJpx) return; + if (!jpxChainStarted) return; + if (!jpxPresent()) return; + + // Stop conditions + if (CFG.chainMaxRounds > 0 && jpxChainRounds >= CFG.chainMaxRounds) { + jpxChainStop('max rounds reached'); + return; + } + if (STATE.hp !== undefined && STATE.hp < CFG.chainMinHP) { + jpxChainStop('HP below ' + Math.round(CFG.chainMinHP * 100) + '%'); + return; + } + + if (jpxRoundEnded()) { + // Round ended, continue button visible → re-trigger auto for next round + jpxChainRounds++; + jpxTriggerAuto(); + console.log(`%c[HV] ⛓ chaining round ${jpxChainRounds}${CFG.chainMaxRounds ? '/' + CFG.chainMaxRounds : ''}`, 'color:#0f0'); + } +} + +function jpxChainStop(reason) { + if (!jpxChainStarted) return; + jpxChainStarted = false; + console.log('%c[HV] ⛓ jpx auto-chain stopped: ' + reason, 'color:#f80'); +} + +function jpxChainReset() { + jpxChainStarted = false; + jpxChainRounds = 0; +} + // ═══════════════════════════════════════════════════════════════════════ // ACTION EXECUTOR — carry out recommended actions // ═══════════════════════════════════════════════════════════════════════ @@ -1772,6 +1885,11 @@ function openSettings() { ${mkTog('Advisor panel', 'showGuidance')} ${mkTog('Equip KEEP/SELL tags', 'showEquipAdvice')} ${mkTog('Config button', 'cfgButton')} +
+ jpx Bridge + ${mkTog('Auto-chain jpx rounds (re-press M)', 'autoChainJpx')} + ${mkNum('Min HP% to keep chaining', 'chainMinHP', 0.05)} + ${mkNum('Max chained rounds (0=∞)', 'chainMaxRounds', 1)}
Changes apply immediately. Press , to open settings.
`; @@ -3618,6 +3736,9 @@ window.HV = { tier: () => STATE.tier, stats: () => JSON.parse(localStorage[SP + 'stats'] || '{"battles":0,"credits":0,"drops":0}'), settings: () => toggleSettings(), + // jpx bridge control + chain: (on) => { CFG.autoChainJpx = !!on; saveConfig(); if (on) { jpxChainStart(); if (!jpxChainTimer) jpxChainTimer = setInterval(jpxChainTick, 1500); } else { jpxChainStop('manual'); } return 'jpx chain: ' + CFG.autoChainJpx; }, + chainState: () => ({ active: jpxChainStarted, rounds: jpxChainRounds, jpx: jpxPresent(), maxRounds: CFG.chainMaxRounds, minHP: CFG.chainMinHP }), advice: () => { const s = detectFightingStyle(); return { style: s, attrs: getAttrAdvice(s), spells: getSpellAdvice(), difficulty: getDifficultyAdvice() }; @@ -3748,6 +3869,21 @@ function initializeBattle() { vobs.observe(vitals, { childList: true, subtree: true, attributes: true }); } + // jpx bridge: engage the round-chaining loop + if (CFG.autoChainJpx) { + jpxChainStart(); + if (!jpxChainTimer) { + jpxChainTimer = setInterval(jpxChainTick, 1500); + } + // Watch the continue button for round-end → next round trigger + const btcp = document.getElementById('btcp'); + if (btcp && !btcp.dataset.hvObserved) { + btcp.dataset.hvObserved = '1'; + const bobs = new MutationObserver(() => jpxChainTick()); + bobs.observe(btcp, { attributes: true, attributeFilter: ['style', 'class', 'visibility', 'display'] }); + } + } + // Auto-scrape gear data on character/armory pages const url = window.location.href || ''; if (url.includes('ss=eq') || url.includes('ss=ch') || url.includes('ss=am')) { diff --git a/src/config.js b/src/config.js index 912616a..a00cb66 100644 --- a/src/config.js +++ b/src/config.js @@ -2,7 +2,7 @@ // CONFIG — default settings // ═══════════════════════════════════════════════════════════════════════ -const VERSION = '0.16.3'; +const VERSION = '0.17.0'; const CFG = { // — Battle strategy advisory @@ -29,5 +29,10 @@ const CFG = { showGuidance: true, showEquipAdvice: true, autoDifficulty: true, + + // — jpx integration (bridge) + autoChainJpx: true, // Re-trigger jpx auto-battle (M) after each round + chainMinHP: 0.25, // Pause chaining below this HP + chainMaxRounds: 0, // 0 = unlimited; else stop after N rounds }; diff --git a/src/header.user.js b/src/header.user.js index 02c3d8b..679d05b 100644 --- a/src/header.user.js +++ b/src/header.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name HV Unified // @namespace hvunified -// @version 0.16.3 +// @version 0.17.0 // @description HV Unified — Bridge & Strategy/Gear Advisor for HentaiVerse (complements Monsterbation & HVUtils) // @author GaboGG + Hermes // @match *://*.hentaiverse.org/* diff --git a/src/init.js b/src/init.js index 3227701..72f55ee 100644 --- a/src/init.js +++ b/src/init.js @@ -67,6 +67,21 @@ function initializeBattle() { vobs.observe(vitals, { childList: true, subtree: true, attributes: true }); } + // jpx bridge: engage the round-chaining loop + if (CFG.autoChainJpx) { + jpxChainStart(); + if (!jpxChainTimer) { + jpxChainTimer = setInterval(jpxChainTick, 1500); + } + // Watch the continue button for round-end → next round trigger + const btcp = document.getElementById('btcp'); + if (btcp && !btcp.dataset.hvObserved) { + btcp.dataset.hvObserved = '1'; + const bobs = new MutationObserver(() => jpxChainTick()); + bobs.observe(btcp, { attributes: true, attributeFilter: ['style', 'class', 'visibility', 'display'] }); + } + } + // Auto-scrape gear data on character/armory pages const url = window.location.href || ''; if (url.includes('ss=eq') || url.includes('ss=ch') || url.includes('ss=am')) { diff --git a/src/jpx-bridge.js b/src/jpx-bridge.js new file mode 100644 index 0000000..673cd45 --- /dev/null +++ b/src/jpx-bridge.js @@ -0,0 +1,107 @@ +// ═══════════════════════════════════════════════════════════════════════ +// JPX BRIDGE — chain jpx's single-round auto-battler into continuous play. +// jpx (by design) runs ONE round per M press. We re-trigger it after each +// round completes, with safety guards. We NEVER re-implement battle logic — +// jpx executes; we bridge rounds. +// ═══════════════════════════════════════════════════════════════════════ + +let jpxChainStarted = false; // chain engaged for current battle +let jpxChainRounds = 0; // rounds chained this battle +let jpxChainTimer = null; + +function jpxPresent() { + return !!(document.getElementById('ctrl-widget') || + document.getElementById('homosex') === null && document.querySelector('.ctrl-widget, #ctrl-widget')); +} + +// Trigger jpx's auto-battle toggle (equivalent to pressing M). +// jpx listens for keydown on document (capture phase) and maps key 'm' to +// toggleActive via userKeybinds. A synthetic KeyboardEvent works because +// jpx reads e.key, not e.isTrusted. +function jpxTriggerAuto() { + try { + const evt = new KeyboardEvent('keydown', { + key: 'm', + code: 'KeyM', + keyCode: 77, + which: 77, + bubbles: true, + cancelable: true, + }); + document.dispatchEvent(evt); + return true; + } catch (e) { + console.warn('[HV] jpxTriggerAuto failed:', e.message); + return false; + } +} + +// Is a round currently active (monsters present)? +function jpxRoundActive() { + const monsters = document.querySelectorAll('.btm1'); + const btcp = document.getElementById('btcp'); + // Round is active when monsters exist and the continue button is NOT visible + return monsters.length > 0 && !(btcp && btcp.style.visibility !== 'hidden' && btcp.style.display !== 'none'); +} + +// Check if the continue/next-round button is up (round ended) +function jpxRoundEnded() { + const btcp = document.getElementById('btcp'); + if (btcp) { + const vis = btcp.style.visibility; + const disp = btcp.style.display; + // jpx hides it (visibility:hidden) after clicking; visible = round ended + if (vis !== 'hidden' && disp !== 'none') return true; + } + // Fallback: finish-battle button present means battle over (not round) + if (document.querySelector('img[src$="finishbattle.png"]')) return false; + return false; +} + +// Try to start the chain (called after battle initializes) +function jpxChainStart() { + if (!CFG.autoChainJpx) return; + if (jpxChainStarted) return; + if (!jpxPresent()) return; + + jpxChainStarted = true; + jpxChainRounds = 0; + // Start the first auto round (equivalent to pressing M once) + setTimeout(jpxTriggerAuto, 800); + console.log('%c[HV] ⛓ jpx auto-chain engaged (re-triggering M each round)', 'color:#0f0'); +} + +// Called periodically while in battle — re-trigger when a round ends +function jpxChainTick() { + if (!CFG.autoChainJpx) return; + if (!jpxChainStarted) return; + if (!jpxPresent()) return; + + // Stop conditions + if (CFG.chainMaxRounds > 0 && jpxChainRounds >= CFG.chainMaxRounds) { + jpxChainStop('max rounds reached'); + return; + } + if (STATE.hp !== undefined && STATE.hp < CFG.chainMinHP) { + jpxChainStop('HP below ' + Math.round(CFG.chainMinHP * 100) + '%'); + return; + } + + if (jpxRoundEnded()) { + // Round ended, continue button visible → re-trigger auto for next round + jpxChainRounds++; + jpxTriggerAuto(); + console.log(`%c[HV] ⛓ chaining round ${jpxChainRounds}${CFG.chainMaxRounds ? '/' + CFG.chainMaxRounds : ''}`, 'color:#0f0'); + } +} + +function jpxChainStop(reason) { + if (!jpxChainStarted) return; + jpxChainStarted = false; + console.log('%c[HV] ⛓ jpx auto-chain stopped: ' + reason, 'color:#f80'); +} + +function jpxChainReset() { + jpxChainStarted = false; + jpxChainRounds = 0; +} diff --git a/src/public-api.js b/src/public-api.js index b445e51..0d3dc6f 100644 --- a/src/public-api.js +++ b/src/public-api.js @@ -11,6 +11,9 @@ window.HV = { tier: () => STATE.tier, stats: () => JSON.parse(localStorage[SP + 'stats'] || '{"battles":0,"credits":0,"drops":0}'), settings: () => toggleSettings(), + // jpx bridge control + chain: (on) => { CFG.autoChainJpx = !!on; saveConfig(); if (on) { jpxChainStart(); if (!jpxChainTimer) jpxChainTimer = setInterval(jpxChainTick, 1500); } else { jpxChainStop('manual'); } return 'jpx chain: ' + CFG.autoChainJpx; }, + chainState: () => ({ active: jpxChainStarted, rounds: jpxChainRounds, jpx: jpxPresent(), maxRounds: CFG.chainMaxRounds, minHP: CFG.chainMinHP }), advice: () => { const s = detectFightingStyle(); return { style: s, attrs: getAttrAdvice(s), spells: getSpellAdvice(), difficulty: getDifficultyAdvice() }; diff --git a/src/settings-panel.js b/src/settings-panel.js index 4193118..d793de0 100644 --- a/src/settings-panel.js +++ b/src/settings-panel.js @@ -72,6 +72,11 @@ function openSettings() { ${mkTog('Advisor panel', 'showGuidance')} ${mkTog('Equip KEEP/SELL tags', 'showEquipAdvice')} ${mkTog('Config button', 'cfgButton')} +
+ jpx Bridge + ${mkTog('Auto-chain jpx rounds (re-press M)', 'autoChainJpx')} + ${mkNum('Min HP% to keep chaining', 'chainMinHP', 0.05)} + ${mkNum('Max chained rounds (0=∞)', 'chainMaxRounds', 1)}
Changes apply immediately. Press , to open settings.
`;