|
| 1 | +/* modules/rolls.js |
| 2 | + * Extra rollers for GATOR: Hit Location, Critical Check, Missile Cluster |
| 3 | + * Canon: Classic BattleTech (front/rear tables, crit counts, cluster tables) |
| 4 | + * UI Mount: window.mountGatorRollsUnderTN() — injects a small control block under TN/Dice |
| 5 | + */ |
| 6 | +(() => { |
| 7 | + 'use strict'; |
| 8 | + |
| 9 | + // ================================ |
| 10 | + // RNG helpers |
| 11 | + // ================================ |
| 12 | + const RNG = { |
| 13 | + d6() { return (Math.random() * 6 | 0) + 1; }, |
| 14 | + roll2d6() { return this.d6() + this.d6(); }, |
| 15 | + }; |
| 16 | + |
| 17 | + // ================================ |
| 18 | + // Canon data tables |
| 19 | + // ================================ |
| 20 | + // ’Mech hit location (FRONT) — 2d6 |
| 21 | + // 2 CT • 3 RT • 4 RA • 5 RL • 6 RT • 7 CT • 8 LT • 9 LL • 10 LA • 11 LA • 12 Head |
| 22 | + const HIT_FRONT = Object.freeze({ |
| 23 | + 2: 'CT', |
| 24 | + 3: 'RT', |
| 25 | + 4: 'RA', |
| 26 | + 5: 'RL', |
| 27 | + 6: 'RT', |
| 28 | + 7: 'CT', |
| 29 | + 8: 'LT', |
| 30 | + 9: 'LL', |
| 31 | + 10: 'LA', |
| 32 | + 11: 'LA', |
| 33 | + 12: 'HEAD', |
| 34 | + }); |
| 35 | + |
| 36 | + // ’Mech hit location (REAR) — same dice mapping, rear notation |
| 37 | + const HIT_REAR = Object.freeze({ |
| 38 | + 2: 'CT (Rear)', |
| 39 | + 3: 'RT (Rear)', |
| 40 | + 4: 'RA (Rear)', |
| 41 | + 5: 'RL (Rear)', |
| 42 | + 6: 'RT (Rear)', |
| 43 | + 7: 'CT (Rear)', |
| 44 | + 8: 'LT (Rear)', |
| 45 | + 9: 'LL (Rear)', |
| 46 | + 10: 'LA (Rear)', |
| 47 | + 11: 'LA (Rear)', |
| 48 | + 12: 'HEAD', // head has no rear |
| 49 | + }); |
| 50 | + |
| 51 | + // Critical check: 2–7:0 • 8–9:1 • 10–11:2 • 12:3 |
| 52 | + const CRIT_BY_ROLL = Object.freeze({ |
| 53 | + 2:0,3:0,4:0,5:0,6:0,7:0, |
| 54 | + 8:1,9:1, |
| 55 | + 10:2,11:2, |
| 56 | + 12:3, |
| 57 | + }); |
| 58 | + |
| 59 | + // Missile Cluster Hits Tables (2..12 → hits), per launcher size |
| 60 | + // Index 0 unused; array[i] corresponds to roll i. |
| 61 | + const CLUSTER = Object.freeze({ |
| 62 | + 2: [ ,1,1,1,1,1,1,1,2,2,2,2,2 ], |
| 63 | + 4: [ ,1,2,2,2,2,2,3,3,3,3,4,4 ], |
| 64 | + 5: [ ,1,2,2,3,3,3,3,3,4,4,5,5 ], |
| 65 | + 6: [ ,2,2,3,3,4,4,4,4,5,5,6,6 ], |
| 66 | + 10: [ ,3,3,4,6,6,6,6,6,8,8,10,10 ], |
| 67 | + 15: [ ,5,5,6,9,9,9,9,9,12,12,15,15 ], |
| 68 | + 20: [ ,6,6,9,12,12,12,12,12,16,16,20,20 ], |
| 69 | + }); |
| 70 | + |
| 71 | + // ================================ |
| 72 | + // Core API |
| 73 | + // ================================ |
| 74 | + function roll2d6(){ return RNG.roll2d6(); } |
| 75 | + |
| 76 | + function rollLocation(facing = 'front'){ |
| 77 | + const r = RNG.roll2d6(); |
| 78 | + const table = (facing === 'rear') ? HIT_REAR : HIT_FRONT; |
| 79 | + return { roll: r, location: table[r] ?? '—' }; |
| 80 | + } |
| 81 | + |
| 82 | + function rollCrit(){ |
| 83 | + const r = RNG.roll2d6(); |
| 84 | + return { roll: r, crits: CRIT_BY_ROLL[r] ?? 0 }; |
| 85 | + } |
| 86 | + |
| 87 | + /** |
| 88 | + * rollCluster({ size, mods=0, streak=false }) |
| 89 | + * size: 2,4,5,6,10,15,20 |
| 90 | + * mods: cluster modifiers (e.g., +2 Artemis, +2 NARC, -1 Indirect) |
| 91 | + * streak: Streak SRMs — on hit, all missiles; on miss, zero (skip table) |
| 92 | + */ |
| 93 | + function rollCluster(opts = {}){ |
| 94 | + const { size = 10, mods = 0, streak = false } = opts; |
| 95 | + if (streak) { |
| 96 | + return { roll: null, adj: null, hits: size, size, note: 'STREAK: full hits on success (skip cluster table)' }; |
| 97 | + } |
| 98 | + const base = RNG.roll2d6(); |
| 99 | + const adj = clamp(base + (mods|0), 2, 12); |
| 100 | + const row = CLUSTER[size]; |
| 101 | + const hits = row ? (row[adj] || 0) : 0; |
| 102 | + return { roll: base, adj, hits, size }; |
| 103 | + } |
| 104 | + |
| 105 | + // ================================ |
| 106 | + // Utils |
| 107 | + // ================================ |
| 108 | + function clamp(n, lo, hi){ return n < lo ? lo : (n > hi ? hi : n); } |
| 109 | + function esc(s){ return String(s??'').replace(/[&<>"']/g,c=>({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c])); } |
| 110 | + |
| 111 | + // ================================ |
| 112 | + // Expose API (no UI) |
| 113 | + // ================================ |
| 114 | + window.GATOR_ROLLS = Object.freeze({ |
| 115 | + version: '1.1.0', |
| 116 | + roll2d6, |
| 117 | + rollLocation, |
| 118 | + rollCrit, |
| 119 | + rollCluster, |
| 120 | + __tables: { HIT_FRONT, HIT_REAR, CRIT_BY_ROLL, CLUSTER }, |
| 121 | + }); |
| 122 | + |
| 123 | + // ================================ |
| 124 | + // Lightweight CSS (fits TRS:80 theme) |
| 125 | + // ================================ |
| 126 | + // ---- GATOR Rolls — style injection to match app theme (stacked under TN) ---- |
| 127 | + (function injectGatorRollsCSS(){ |
| 128 | + if (document.getElementById('gtrx-css')) return; |
| 129 | + const css = ` |
| 130 | + /* Extra Rolls block */ |
| 131 | + #gtr-extra-rolls{ |
| 132 | + margin-top: 12px; |
| 133 | + padding-top: 10px; |
| 134 | + border-top: 1px solid var(--border); |
| 135 | + font-size: 13px; |
| 136 | + } |
| 137 | +
|
| 138 | + /* Controls row */ |
| 139 | + #gtr-extra-rolls .gtrx-row{ |
| 140 | + display:flex; flex-wrap:wrap; gap:8px 10px; align-items:center; |
| 141 | + } |
| 142 | + #gtr-extra-rolls .gtrx-sep{ width:1px; height:16px; background:var(--border); margin:0 2px; } |
| 143 | + #gtr-extra-rolls .gtrx-label{ color:var(--muted); font-size:12px; } |
| 144 | +
|
| 145 | + /* Readout sits BELOW controls, full width */ |
| 146 | + #gtr-extra-rolls .gtrx-out{ |
| 147 | + margin-top:8px; |
| 148 | + padding:6px 8px; |
| 149 | + border:1px solid var(--border); |
| 150 | + border-radius:6px; |
| 151 | + background:linear-gradient(180deg, rgba(255,255,255,.02), rgba(0,0,0,.02)); |
| 152 | + font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace; |
| 153 | + display:flex; align-items:center; gap:10px; flex-wrap:wrap; |
| 154 | + min-height:32px; |
| 155 | + } |
| 156 | + #gtr-extra-rolls .gtrx-badge{ |
| 157 | + border-radius:999px; padding:4px 8px; |
| 158 | + background:rgba(255,255,255,.04); |
| 159 | + font-weight:800; font-size:12.5px; |
| 160 | + } |
| 161 | + #gtr-extra-rolls .gtrx-note{ color:var(--muted); font-size:12px; } |
| 162 | +
|
| 163 | + /* Theme-consistent controls */ |
| 164 | + #gtr-extra-rolls .btn.sm{ line-height:1; } |
| 165 | + #gtr-extra-rolls .gtr-in.tiny{ |
| 166 | + width:64px; text-align:center; padding:6px 8px; |
| 167 | + border-radius:6px; border:1px solid var(--border); background:#0e1522; color:var(--ink); |
| 168 | + } |
| 169 | + #gtr-extra-rolls select.gtr-sel{ |
| 170 | + padding:6px 8px; border-radius:6px; border:1px solid var(--border); background:#0e1522; color:var(--ink); |
| 171 | + } |
| 172 | + `; |
| 173 | + const st = document.createElement('style'); |
| 174 | + st.id = 'gtrx-css'; |
| 175 | + st.textContent = css; |
| 176 | + document.head.appendChild(st); |
| 177 | + })(); |
| 178 | + |
| 179 | + |
| 180 | + // ================================ |
| 181 | + // Mount helper — attach UI under current TN/Dice section |
| 182 | + // ================================ |
| 183 | + // ---- Mount helper to place the UI directly BELOW the Target Number ---- |
| 184 | + window.mountGatorRollsUnderTN = function mountGatorRollsUnderTN(){ |
| 185 | + if (!window.GATOR_ROLLS) { console.warn('[gator] rolls API not ready'); return; } |
| 186 | + |
| 187 | + // Find the Target Number block and mount AFTER its footer container |
| 188 | + const tnEl = document.getElementById('gtr-total'); |
| 189 | + const footer = tnEl ? tnEl.closest('.gtr-footer') : null; |
| 190 | + |
| 191 | + // Fallbacks just in case the footer isn't present for some reason |
| 192 | + const anchor = |
| 193 | + footer || |
| 194 | + document.getElementById('roll-att-detail') || |
| 195 | + document.getElementById('btn-roll-both') || |
| 196 | + document.getElementById('btn-roll-att'); |
| 197 | + |
| 198 | + if (!anchor) { console.warn('[gator] TN anchor not found'); return; } |
| 199 | + if (document.getElementById('gtr-extra-rolls')) return; // already mounted |
| 200 | + |
| 201 | + const wrap = document.createElement('div'); |
| 202 | + wrap.id = 'gtr-extra-rolls'; |
| 203 | + wrap.innerHTML = ` |
| 204 | + <div class="gtrx-row"> |
| 205 | + <button id="gtr-loc-front" type="button" class="btn sm">Location (Front)</button> |
| 206 | + <button id="gtr-loc-rear" type="button" class="btn sm">Location (Rear)</button> |
| 207 | + <button id="gtr-crit" type="button" class="btn sm">Critical Check</button> |
| 208 | +
|
| 209 | + <span class="gtrx-sep" aria-hidden="true"></span> |
| 210 | +
|
| 211 | + <span class="gtrx-label">Missiles:</span> |
| 212 | + <input id="gtr-miss-size" class="gtr-in tiny" type="number" min="2" max="20" value="10" /> |
| 213 | + <select id="gtr-miss-type" class="gtr-sel"> |
| 214 | + <option value="LRM" selected>LRM (1 dmg/shot)</option> |
| 215 | + <option value="SRM">SRM (2 dmg/shot)</option> |
| 216 | + </select> |
| 217 | +
|
| 218 | + <span class="gtrx-sep" aria-hidden="true"></span> |
| 219 | +
|
| 220 | + <label class="gtrx-label"><input id="gtr-mod-artemis" type="checkbox" /> Artemis +2</label> |
| 221 | + <label class="gtrx-label"><input id="gtr-mod-narc" type="checkbox" /> NARC +2</label> |
| 222 | + <label class="gtrx-label"><input id="gtr-mod-indir" type="checkbox" /> Indirect −1</label> |
| 223 | + <span class="gtrx-label">Other</span> |
| 224 | + <input id="gtr-miss-mods" class="gtr-in tiny" type="number" min="-4" max="4" value="0" /> |
| 225 | + <label class="gtrx-label"><input id="gtr-miss-streak" type="checkbox" /> Streak</label> |
| 226 | +
|
| 227 | + <button id="gtr-miss" type="button" class="btn sm">Roll Cluster</button> |
| 228 | + </div> |
| 229 | +RESULTS |
| 230 | + <div id="gtr-extra-out" class="gtrx-out"> |
| 231 | + <span class="gtrx-note">Results will appear here.</span> |
| 232 | + </div> |
| 233 | + `; |
| 234 | + |
| 235 | + // Insert directly AFTER the TN footer so the whole block sits below the TN |
| 236 | + anchor.insertAdjacentElement('afterend', wrap); |
| 237 | + |
| 238 | + // ------- Wiring ------- |
| 239 | + const outEl = document.getElementById('gtr-extra-out'); |
| 240 | + const out = (...parts) => { outEl.innerHTML = parts.join(' '); }; |
| 241 | + const esc = (s)=>String(s??'').replace(/[&<>"']/g,c=>({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c])); |
| 242 | + |
| 243 | + // Hit Location |
| 244 | + document.getElementById('gtr-loc-front')?.addEventListener('click', () => { |
| 245 | + const r = GATOR_ROLLS.rollLocation('front'); |
| 246 | + out(`<span class="gtrx-badge">Hit Loc</span>`, `Front → <b>${esc(r.location)}</b>`, `<span class="gtrx-note">(2d6=${r.roll})</span>`); |
| 247 | + }); |
| 248 | + document.getElementById('gtr-loc-rear')?.addEventListener('click', () => { |
| 249 | + const r = GATOR_ROLLS.rollLocation('rear'); |
| 250 | + out(`<span class="gtrx-badge">Hit Loc</span>`, `Rear → <b>${esc(r.location)}</b>`, `<span class="gtrx-note">(2d6=${r.roll})</span>`); |
| 251 | + }); |
| 252 | + |
| 253 | + // Critical check |
| 254 | + document.getElementById('gtr-crit')?.addEventListener('click', () => { |
| 255 | + const r = GATOR_ROLLS.rollCrit(); |
| 256 | + const label = r.crits ? `<b>${r.crits} critical${r.crits>1?'s':''}</b>` : 'No criticals'; |
| 257 | + out(`<span class="gtrx-badge">Critical</span>`, `${label}`, `<span class="gtrx-note">(2d6=${r.roll})</span>`); |
| 258 | + }); |
| 259 | + |
| 260 | + // Missile roll (clarity-first) |
| 261 | + document.getElementById('gtr-miss')?.addEventListener('click', () => { |
| 262 | + const size = Math.max(2, Math.min(20, +document.getElementById('gtr-miss-size').value || 10)); |
| 263 | + const type = (document.getElementById('gtr-miss-type').value || 'LRM').toUpperCase(); |
| 264 | + const isSRM = type === 'SRM'; |
| 265 | + const dpm = isSRM ? 2 : 1; // damage per missile |
| 266 | + const streak = !!document.getElementById('gtr-miss-streak').checked; |
| 267 | + |
| 268 | + // Auto modifiers |
| 269 | + const modsList = []; |
| 270 | + let autoMods = 0; |
| 271 | + if (document.getElementById('gtr-mod-artemis').checked) { autoMods += 2; modsList.push('+2 Artemis'); } |
| 272 | + if (document.getElementById('gtr-mod-narc').checked) { autoMods += 2; modsList.push('+2 NARC'); } |
| 273 | + if (document.getElementById('gtr-mod-indir').checked) { autoMods -= 1; modsList.push('−1 Indirect'); } |
| 274 | + |
| 275 | + const manual = (+document.getElementById('gtr-miss-mods').value) || 0; |
| 276 | + if (manual) modsList.push((manual>0?'+':'') + manual + ' manual'); |
| 277 | + |
| 278 | + if (streak) { |
| 279 | + const hits = size, dmg = hits * dpm; |
| 280 | + out( |
| 281 | + `<span class="gtrx-badge">Missiles</span>`, |
| 282 | + `<b>STREAK:</b> on a successful to-hit, <b>${hits}/${size}</b> hit (${type} • ${dpm} dmg/shot → <b>${dmg} dmg</b>).`, |
| 283 | + `<span class="gtrx-note">Cluster table skipped.</span>` |
| 284 | + ); |
| 285 | + return; |
| 286 | + } |
| 287 | + |
| 288 | + const res = GATOR_ROLLS.rollCluster({ size, mods: autoMods + manual, streak:false }); |
| 289 | + const hits = res.hits; |
| 290 | + const dmg = hits * dpm; |
| 291 | + |
| 292 | + const modsPart = modsList.length |
| 293 | + ? `<span class="gtrx-note">mods: ${modsList.join(', ')}</span>` |
| 294 | + : `<span class="gtrx-note">mods: none</span>`; |
| 295 | + |
| 296 | + out( |
| 297 | + `<span class="gtrx-badge">Missiles</span>`, |
| 298 | + `${type} size <b>${size}</b> → <b>${hits}/${size}</b> hit`, |
| 299 | + `<span class="gtrx-note">(roll ${res.roll}${(autoMods||manual)?`, adj ${res.adj}`:''})</span>`, |
| 300 | + `<span class="gtrx-badge">${dmg} dmg</span>`, |
| 301 | + modsPart |
| 302 | + ); |
| 303 | + }); |
| 304 | + }; |
| 305 | + |
| 306 | +})(); |
0 commit comments