/* Kode tantangan (RENCANA 7.5) dan kebiasaan bermain lokal (RENCANA 7.6). Gaya: challenge.css. Tanpa jaringan dan tanpa pustaka luar. KODE TANTANGAN (window.Challenge) - Bentuk: 8 karakter dari 30 simbol yang tidak membingungkan (2-9 dan A-Z tanpa I, L, O, U), tampil XXXX-XXXX. Saat mengetik, huruf kecil, spasi, dan tanda hubung diabaikan; tempelan berupa tautan (?kode=...) atau pesan panjang ditelusuri sampai ketemu kode yang lolos pemeriksaan. - Isi 24 bit (versi 2, variasi 2, aturan 4, tingkat 2, arena 4, lawan 5, pemain 5) + 15 bit pemeriksa, lalu dicampur supaya kode yang mirip tidak tampak mirip. Salah ketik lolos pemeriksa dengan peluang sekitar 1 : 32.000 dan masih harus menghasilkan karakter, arena, dan aturan yang ada, jadi praktis selalu ditolak ("kode tidak valid"). "Variasi" dipakai penyandi untuk menghindari kata kasar yang kebetulan terbentuk (BLOCKED); tanpa itu selalu 0. - Daftar karakter (BASE_FIGHTERS), arena (BASE_STAGES), tingkat (LEVELS), dan aturan (RULES) menentukan arti kode: urutannya TIDAK BOLEH diubah, hanya ditambah di belakang. Karakter/arena baru yang belum ada di daftar ikut otomatis di belakangnya menurut urutan deklarasi (window.FIGHTERS, MatchRules.stages); sebaiknya tetap dimasukkan ke daftar di sini. - Aturan khusus (RULES): satu per kode, kecil dan aman, hanya mengubah angka lewat kait sekecil mungkin di game.js. Kuncinya (blitz, one, fast, noblock, heavy) hanya hidup di berkas ini dan di dalam kode; tidak ada yang disimpan di localStorage, jadi tidak bentrok dengan aturan tantangan harian (daily.js). - Alur: Challenge.open() = dialog "Masukkan kode" (tombol KODE TANTANGAN di pojok kiri atas menu utama); ?kode=XXXXXXXX di alamat membuka dialog yang sama sebagai undangan (isi sudah terisi, tinggal TERIMA); Challenge.start(cfg) memulai pertandingan. Layar arena (menu.js) mendapat baris ATURAN / SALIN KODE / TAUTAN, layar hasil tombol SALIN KODE / TAUTAN. Aturan yang dipilih di layar arena berlaku juga untuk tombol LET'S FIGHT dan MAIN LAGI (menu.js start() memanggil Challenge.pending); kembali ke menu utama mengosongkannya. Kait di game.js (semua window.Challenge?.x?.(), jadi tanpa berkas ini game berjalan seperti biasa): begin(options) di startMatch sesudah validasi (juga mencatat kebiasaan bermain), tune(sideMods) di akhir refreshPassives, roundStart(hero, dummy, BALANCE) di akhir resetCombat, roundEnd(match, event) dan finish(info) di advanceMatch, hud(match) di updateMatchHud, dan noBlock di press (tombol L) serta heroGuard. Kait di menu.js: decorate(root, screen, ctx) di akhir render() dan pending(mode, p2) di start(). startMatch menerima opsi codeRule: {rule, code}; hanya berlaku untuk versus melawan CPU (bukan dua pemain, bukan Bayangan). Semua kait dibungkus guard: galat hanya dicatat di konsol. Tidak memakai Math.random/Date.now: aturan tidak mengubah pengacak pertandingan. KEBIASAAN BERMAIN (window.Habits) - localStorage['konoha.habits.v1'] = {v:1, n, ch:{idKarakter:jumlah}, mode:{...}, stage:{...}, foe:{...}}. Dicatat di begin() tiap pertandingan dimulai (latihan juga, Bayangan tidak dihitung sebagai "lawan"). Tanpa waktu, tanpa identitas, tanpa jaringan. Terpisah dari Meta.save, jadi simpanan lama tidak tersentuh; data rusak atau kosong dibaca sebagai nol. - Bagian "Kebiasaan bermainmu" disisipkan ke tab Koleksi lewat MutationObserver (meta.js tidak diubah), lengkap dengan tombol HAPUS STATISTIK. Catatan privasi di layar About lewat decorate(). Konsol: Challenge.state(), Challenge.encode({player, enemy, stage, level, rule}), Challenge.decode('XXXX-XXXX'), Habits.data(). */ (() => { 'use strict'; const KEY_HABITS = 'konoha.habits.v1'; const $ = s => document.querySelector(s); const esc = t => String(t == null ? '' : t).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const guard = (fn, fallback) => (...a) => { try { return fn(...a); } catch (e) { console.error('Challenge:', e); return fallback; } }; const store = { get(k) { try { return localStorage.getItem(k); } catch (_) { return null; } }, set(k, v) { try { localStorage.setItem(k, v); } catch (_) { /* penyimpanan penuh atau diblokir: statistik hanya hidup di sesi ini */ } }, del(k) { try { localStorage.removeItem(k); } catch (_) { /* abaikan */ } } }; const sfx = kind => { try { window.__game?.menuSound?.(kind); } catch (_) { /* suara hanya hiasan */ } }; const say = msg => { try { window.Meta?.toast?.(msg); } catch (_) { /* toast hanya hiasan */ } }; // ---------- nama dan ketersediaan (sama dengan yang dipakai menu.js) ---------- const fighterName = id => window.FIGHTERS?.[id]?.name || (id === 'browo' ? 'BRO WO' : String(id || '?').toUpperCase()); const hasFighter = id => !!(window.FIGHTERS?.[id] || (id === 'browo' && window.Browo && window.BROWO_MANIFEST)); const stageName = id => window.MatchRules?.stages?.[id]?.name || String(id || '?'); const hasStage = id => !!window.MatchRules?.stages && Object.hasOwn(window.MatchRules.stages, id); // ---------- daftar kode (urutan tetap, hanya ditambah di belakang) ---------- const BASE_FIGHTERS = ['browo', 'megachan', 'sonofsolo', 'ethanolman', 'anice', 'soloman', 'gufron']; const BASE_STAGES = ['bellora', 'sunspire', 'harbor', 'elderwood', 'moonrise']; const LEVELS = ['easy', 'medium', 'hard', 'excellent']; const LEVEL_NAME = { easy: 'Mudah', medium: 'Sedang', hard: 'Sulit', excellent: 'Sangat sulit' }; const fighterList = () => BASE_FIGHTERS.concat(Object.keys(window.FIGHTERS || {}).filter(id => !BASE_FIGHTERS.includes(id))); const stageList = () => BASE_STAGES.concat(Object.keys(window.MatchRules?.stages || {}).filter(id => !BASE_STAGES.includes(id))); // Aturan khusus. Kolom: rounds = jumlah ronde penentu (default 3 ronde, menang 2), hp = pecahan HP awal kedua petarung, mods = pengali kunci passiveMod (passives.js KEYS) // per sisi, noBlock = blok dan parry tidak berlaku. Menambah aturan: tambahkan di BELAKANG dan jangan menggeser yang lama. const FAST = { walk: 1.25, cd: 1.35 }, HEAVY = 1.5, HALF = .5, pct = v => Math.round((v - 1) * 100); const RULES = [ { id: 'none', name: 'Tanpa aturan khusus', short: 'BIASA', desc: 'Pertandingan seperti biasa.' }, { id: 'blitz', name: 'Adu cepat', short: 'ADU CEPAT', desc: 'Satu ronde saja dan nyawa kedua petarung tinggal separuh.', rounds: 1, hp: HALF }, { id: 'one', name: 'Satu ronde', short: 'SATU RONDE', desc: 'Hanya satu ronde. Siapa menang, dialah juaranya.', rounds: 1 }, { id: 'fast', name: 'Lawan gesit', short: 'LAWAN GESIT', desc: `Lawan berjalan ${pct(FAST.walk)}% lebih cepat dan jurusnya pulih ${pct(FAST.cd)}% lebih cepat.`, mods: { enemy: FAST } }, { id: 'noblock', name: 'Tanpa blok', short: 'TANPA BLOK', desc: 'Blok dan parry tidak berlaku, untuk kedua petarung.', noBlock: true, mods: { enemy: { parry: 0 } } }, { id: 'heavy', name: 'Pukulan berat', short: 'PUKULAN BERAT', desc: `Semua damage naik ${pct(HEAVY)}%, untuk kedua petarung.`, mods: { player: { dmgOut: HEAVY }, enemy: { dmgOut: HEAVY } } } ]; const ruleOf = id => RULES.find(r => r.id === id) || RULES[0]; // ---------- penyandi ---------- const ALPHA = '23456789ABCDEFGHJKMNPQRSTVWXYZ', BASE = ALPHA.length, TOTAL = 2 ** 39; // 30 simbol; 24 bit isi + 15 bit pemeriksa = 39 bit < 30^8 const BLOCKED = ['SEX', 'ASS', 'SETAN', 'SATAN', 'BANGSAT', 'KAMPRET', 'KEPARAT', 'JAHANAM']; // kata yang tidak boleh muncul kebetulan di kode const mix = x => { x = Math.imul(x ^ (x >>> 16), 0x7feb352d); x = Math.imul(x ^ (x >>> 15), 0x846ca68b); return (x ^ (x >>> 16)) >>> 0; }; const check15 = p => mix((p ^ 0x2f6b9d35) >>> 0) & 0x7fff; const mask24 = c => mix((c + 0x1b873593) >>> 0) & 0xffffff; function pack(p) { const c = check15(p), m = (p ^ mask24(c)) & 0xffffff; let n = m * 32768 + c, out = ''; for (let i = 0; i < 8; i++) { const d = n % BASE; out = ALPHA[d] + out; n = (n - d) / BASE; } return out; } // 24 bit isi, atau -1 bila pemeriksa tidak cocok. function unpack(s) { let n = 0; for (const ch of s) n = n * BASE + ALPHA.indexOf(ch); if (n >= TOTAL) return -1; const c = n % 32768, m = (n - c) / 32768, p = (m ^ mask24(c)) & 0xffffff; return check15(p) === c ? p : -1; } const pretty = code => { const c = String(code || ''); return c.length > 4 ? c.slice(0, 4) + '-' + c.slice(4) : c; }; // K7M2QX9T -> K7M2-QX9T (juga untuk kode yang belum lengkap) // cfg = {player, enemy, stage, level, rule?} -> 'K7M2QX9T' (tanpa tanda hubung) atau null bila ada yang belum bisa disandikan. function encode(cfg) { const fl = fighterList(), sl = stageList(); const a = fl.indexOf(cfg?.player), b = fl.indexOf(cfg?.enemy), s = sl.indexOf(cfg?.stage), l = LEVELS.indexOf(cfg?.level), r = RULES.findIndex(x => x.id === (cfg?.rule || 'none')); if (a < 0 || a > 31 || b < 0 || b > 31 || s < 0 || s > 15 || l < 0 || r < 0 || r > 15) return null; const body = a | (b << 5) | (s << 10) | (l << 14) | (r << 16); let code = ''; for (let variant = 0; variant < 4; variant++) { code = pack(body | (variant << 20)); if (!BLOCKED.some(w => code.includes(w))) break; } return code; } // Mengambil kode dari apa pun yang diketik atau ditempel: kode polos, tautan dengan ?kode=..., atau kalimat yang memuat kode. function extract(raw) { const text = String(raw == null ? '' : raw).trim(); if (!text) return ''; const link = /[?&#]kode=([A-Za-z0-9-]+)/i.exec(text); if (link) return link[1].toUpperCase().replace(/[^A-Z0-9]/g, ''); const flat = text.toUpperCase().replace(/[^A-Z0-9]/g, ''); if (flat.length <= 8) return flat; const up = text.toUpperCase(), re = /([A-Z0-9]{4})[\s-]?([A-Z0-9]{4})/g; let m; while ((m = re.exec(up))) { const c = m[1] + m[2]; if ([...c].every(ch => ALPHA.includes(ch)) && unpack(c) >= 0) return c; re.lastIndex = m.index + 1; } return flat; } const fail = (err, msg, soft) => ({ ok: false, err, msg, soft: !!soft }); // -> {ok:true, code, cfg:{player, enemy, stage, level, rule}} atau {ok:false, err, msg, soft}; soft = belum selesai diketik (bukan galat). function decode(raw) { const s = extract(raw); if (!s) return fail('empty', 'Ketik atau tempel kode tantangannya dulu.', true); if (s.length > 8) return fail('long', 'Tidak ketemu kode tantangan yang valid di teks ini. Kode terdiri dari 8 huruf dan angka.'); if ([...s].some(ch => !ALPHA.includes(ch))) return fail('chars', 'Ada karakter yang tidak dipakai di kode tantangan (0, 1, I, L, O, U). Periksa lagi ya.'); if (s.length < 8) return fail('short', `Kodenya baru ${s.length} dari 8 karakter.`, true); const p = unpack(s); if (p < 0) return fail('check', 'Kode ini tidak valid. Mungkin ada huruf atau angka yang salah ketik.'); if (p >>> 22) return fail('version', 'Kode ini dibuat oleh versi game yang lebih baru. Muat ulang atau perbarui game dulu.'); const fl = fighterList(), sl = stageList(); const cfg = { player: fl[p & 31], enemy: fl[(p >> 5) & 31], stage: sl[(p >> 10) & 15], level: LEVELS[(p >> 14) & 3], rule: RULES[(p >> 16) & 15]?.id }; if (!cfg.rule || !hasFighter(cfg.player) || !hasFighter(cfg.enemy) || !cfg.stage || !hasStage(cfg.stage)) return fail('unknown', 'Kode ini memakai karakter, arena, atau aturan yang belum ada di game ini.'); return { ok: true, code: s, cfg }; } function shareUrl(code) { try { const u = new URL(location.href); u.search = ''; u.hash = ''; if (u.protocol !== 'file:') u.pathname = u.pathname.replace(/index\.html$/, ''); return u.href + '?kode=' + code; } catch (_) { return '?kode=' + code; } } // Papan klip: API modern, lalu execCommand, lalu jendela prompt supaya pemain bisa menyalin sendiri. Hasil true = sudah tersalin. async function copyText(text) { try { if (navigator.clipboard?.writeText && window.isSecureContext !== false) { await navigator.clipboard.writeText(text); return true; } } catch (_) { /* coba cadangan */ } try { const ta = document.createElement('textarea'); ta.value = text; ta.setAttribute('readonly', ''); ta.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0'; document.body.append(ta); ta.select(); ta.setSelectionRange(0, text.length); const ok = document.execCommand('copy'); ta.remove(); if (ok) return true; } catch (_) { /* coba prompt */ } try { window.prompt('Salin teks ini (Ctrl+C), lalu bagikan:', text); } catch (_) { /* tanpa prompt: tidak ada yang bisa dilakukan */ } return false; } // ---------- keadaan dan kait mesin ---------- let selected = 'none'; // aturan yang dipilih di layar arena atau dari kode; dibaca menu.js start() lewat pending() let active = null; // {rule, code} selama pertandingan berkode berjalan; kosong di pertandingan biasa let last = { rule: 'none' }; // pertandingan terakhir (untuk SALIN KODE di layar hasil) const cur = () => (active ? ruleOf(active.rule) : null); const label = r => 'TANTANGAN' + (r && r.id !== 'none' ? ' · ' + r.short : ''); const els = {}, q = sel => (els[sel] && els[sel].isConnected ? els[sel] : (els[sel] = $(sel))); const setText = (sel, t) => { const e = q(sel); if (e && e.textContent !== t) e.textContent = t; }; function paintBadge(rule) { const host = $('.match-center'); if (!host) return; let b = host.querySelector('.ch-badge'); if (!rule) { b?.remove(); return; } if (!b) { b = document.createElement('div'); b.className = 'ch-badge'; b.setAttribute('role', 'note'); host.append(b); } b.textContent = label(rule); b.title = rule.desc; } const begin = guard(options => { const o = options || {}, mode = o.mode === undefined ? 'versus' : o.mode, c = o.codeRule; track(o); // kebiasaan bermain (bagian bawah); galatnya tidak boleh menghentikan sisa kait ini active = c && typeof c === 'object' && mode === 'versus' && !(o.vs && o.vs.p2) && !o.shadow ? { rule: ruleOf(c.rule).id, code: String(c.code || '') } : null; if (active && o.vs) { const vs = typeof o.vs === 'object' ? o.vs : {}; if (!vs.label) o.vs = { ...vs, label: label(ruleOf(active.rule)) }; } // judul layar VS last = { rule: active ? active.rule : 'none' }; paintBadge(active ? ruleOf(active.rule) : null); }); // refreshPassives: aturan dikalikan ke angka pasif tiap sisi (kunci dan arti sama dengan passiveMod). const tune = guard(mods => { const r = cur(); if (!r?.mods || !mods) return; for (const side of ['player', 'enemy']) { const src = r.mods[side], dst = mods[side]; if (src && dst) for (const k of Object.keys(src)) dst[k] = (dst[k] === undefined ? 1 : dst[k]) * src[k]; } }); // resetCombat (awal pertandingan dan tiap ronde): HP awal. Maksimum HP tetap, jadi bilah dan penentu waktu habis memakai skala yang sama; yang berubah hanya titik awalnya. const roundStart = guard((hero, dummy, bal) => { const r = cur(); if (!r?.hp || !hero || !dummy || !bal) return; hero.hp = Math.max(1, Math.round(bal.heroMax * r.hp)); dummy.damage = bal.dummyMax - Math.max(1, Math.round(bal.dummyMax * r.hp)); }); // Satu ronde: begitu ronde berakhir dengan pemenang, pertandingan dianggap selesai (seri tetap mengulang ronde). MatchRules.tick sudah menaruh match.winner = null di sini. const roundEnd = guard((m, e) => { const r = cur(); if (r?.rounds === 1 && m && !m.winner && e && e.winner && e.winner !== 'draw') m.winner = e.winner; }); // Pertandingan selesai: ingat pengaturannya untuk SALIN KODE di layar hasil. "Bersih" (menang tanpa kehilangan ronde) tidak berlaku bila hanya ada satu ronde. const finish = guard(info => { if (!info) return; const r = cur(); last = { player: info.player, enemy: info.enemy, stage: info.stage, level: info.level, rule: r ? r.id : 'none' }; if (r?.rounds === 1) info.perfect = false; }); // Teks HUD untuk aturan satu ronde (updateMatchHud menulis ulang tiap frame; ini menimpanya sesudah itu, hanya bila perlu). const hud = guard(m => { const r = cur(); if (r?.rounds !== 1 || !m || m.mode !== 'versus') return; setText('#mode-label', 'SATU RONDE'); setText('#round-wins-player', m.playerWins ? '◆' : '◇'); setText('#round-wins-enemy', m.enemyWins ? '◆' : '◇'); const d = q('#round-call-detail'); if (d && d.textContent === 'FIRST TO TWO WINS') d.textContent = 'SATU RONDE MENENTUKAN'; }); const pending = (mode, p2) => (selected !== 'none' && mode === 'versus' && !p2 ? { rule: selected } : null); try { new MutationObserver(() => { if (active && document.body.classList.contains('in-menu')) active = null; }).observe(document.body, { attributes: true, attributeFilter: ['class'] }); } catch (_) { /* tanpa pengamat: pertandingan berikutnya tetap menimpa */ } // ---------- memulai pertandingan dari kode ---------- function start(cfg, code) { const g = window.__game; if (!g || !cfg) return false; const rule = ruleOf(cfg.rule), before = selected; selected = rule.id; let ok = false; try { window.Meta?.cancelRun?.(); ok = !!g.startMatch({ player: cfg.player, enemy: cfg.enemy, stage: cfg.stage, level: cfg.level, mode: 'versus', vs: { label: label(rule) }, codeRule: { rule: rule.id, code: code || '' } }); } catch (e) { console.error(e); } if (!ok) selected = before; return ok; } // ---------- dialog Masukkan kode / undangan ---------- let dlg = null, parsed = null; function summaryHtml(cfg) { const rule = ruleOf(cfg.rule); return `
${esc(fighterName(cfg.player))}VS${esc(fighterName(cfg.enemy))}
` + `
Arena ${esc(stageName(cfg.stage))} · Tingkat ${esc(LEVEL_NAME[cfg.level])}
` + `
Aturan ${esc(rule.name)}${rule.id === 'none' ? '' : ' · ' + esc(rule.desc)}
`; } function refresh() { const input = dlg.querySelector('#ch-input'), box = dlg.querySelector('#ch-preview'), go = dlg.querySelector('#ch-go'); const r = parsed = decode(input.value); if (r.ok) { box.className = 'ch-preview ok'; box.innerHTML = summaryHtml(r.cfg); go.disabled = false; } else { box.className = 'ch-preview ' + (r.soft ? 'hint' : 'err'); box.textContent = r.msg; go.disabled = true; } } // Selama mengetik kode polos, tanda hubung di tengah dipasang otomatis (hanya bila kursor di ujung, supaya penyuntingan di tengah tidak melompat). function reformat(input) { const v = input.value; if (!/^[A-Za-z0-9\s-]*$/.test(v) || input.selectionStart !== v.length) return; const flat = v.toUpperCase().replace(/[^A-Z0-9]/g, ''); if (flat.length > 8) return; const t = pretty(flat); if (t !== v) input.value = t; } function closeDialog() { if (dlg?.open) { try { dlg.close(); } catch (_) { dlg.removeAttribute('open'); } } } function go() { if (!dlg || !parsed) return; const input = dlg.querySelector('#ch-input'); if (!parsed.ok) { sfx('locked'); input.focus(); return; } sfx('confirm'); if (start(parsed.cfg, parsed.code)) { closeDialog(); $('#arena')?.focus(); return; } const box = dlg.querySelector('#ch-preview'); box.className = 'ch-preview err'; box.textContent = 'Aset game belum siap. Tunggu sebentar, lalu coba lagi.'; } function build() { dlg = document.createElement('dialog'); dlg.id = 'ch-dialog'; dlg.setAttribute('aria-labelledby', 'ch-title'); dlg.innerHTML = '

' + '' + '
' + '
'; document.body.append(dlg); const input = dlg.querySelector('#ch-input'); input.addEventListener('input', () => { reformat(input); refresh(); }); input.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); go(); } }); // Selama dialog terbuka tidak ada tombol yang boleh bocor ke menu atau game (Esc ditutup di sini sendiri, karena game.js memanggil preventDefault pada Esc). dlg.addEventListener('keydown', e => { e.stopPropagation(); if (e.key === 'Escape') { e.preventDefault(); closeDialog(); } }); dlg.addEventListener('keyup', e => e.stopPropagation()); dlg.addEventListener('cancel', e => { e.preventDefault(); closeDialog(); }); dlg.querySelector('#ch-go').addEventListener('click', go); dlg.querySelector('#ch-close').addEventListener('click', () => { sfx('back'); closeDialog(); }); } // opts.raw = isi awal (kode, tautan, atau pesan); opts.invite = tampil sebagai undangan dari tautan ?kode=. const open = guard((opts = {}) => { if (!dlg || !dlg.isConnected) build(); const invite = !!opts.invite, input = dlg.querySelector('#ch-input'); dlg.dataset.invite = invite ? '1' : ''; dlg.querySelector('#ch-kicker').textContent = invite ? 'KONOHA FIGHTING / TANTANGAN' : 'KONOHA FIGHTING / KODE TANTANGAN'; dlg.querySelector('#ch-title').textContent = invite ? 'Ada yang menantangmu!' : 'Masukkan kode'; dlg.querySelector('#ch-lead').textContent = invite ? 'Temanmu mengirim tautan tantangan. Cek pengaturannya, lalu mulai kalau sudah siap.' : 'Tempel atau ketik kode dari temanmu. Pertandingan langsung dimulai dengan pengaturan yang sama.'; dlg.querySelector('#ch-go').innerHTML = (invite ? 'TERIMA TANTANGAN' : 'MULAI TANTANGAN') + ' ↗'; dlg.querySelector('#ch-close').textContent = invite ? 'Nanti saja' : 'Batal'; const raw = String(opts.raw || '').trim(), code = extract(raw); input.value = code && code.length <= 8 ? pretty(code) : raw; refresh(); try { dlg.showModal(); } catch (_) { dlg.setAttribute('open', ''); } (invite && parsed?.ok ? dlg.querySelector('#ch-go') : input).focus(); }); // Tautan ?kode=...: tawarkan tantangannya sesudah layar muat selesai, lalu hapus parameter dari alamat supaya muat ulang tidak menawarkan lagi. function dropParam() { const strip = w => { try { const u = new URL(w.location.href); if (!u.searchParams.has('kode')) return; u.searchParams.delete('kode'); w.history.replaceState(null, '', u.pathname + u.search + u.hash); } catch (_) { /* alamat tidak bisa diubah: biarkan */ } }; strip(window); try { if (window.parent !== window) strip(window.parent); } catch (_) { /* induk lintas asal */ } } try { const raw = new URLSearchParams(location.search).get('kode'); // Kode di tautan yang valid tampil sebagai undangan; yang tidak valid atau kosong membuka dialog biasa (dengan pesan galatnya) supaya bisa diperbaiki. if (raw !== null) Promise.resolve(window.AETHER_PRELOAD).then(() => setTimeout(() => { open({ invite: decode(raw).ok, raw }); dropParam(); }, 650)); } catch (_) { /* tanpa parameter */ } // ---------- tombol di layar menu ---------- function flash(btn, text) { if (!btn) return; const old = btn.dataset.ch0 ?? (btn.dataset.ch0 = btn.textContent); btn.textContent = text; clearTimeout(btn._ch); btn._ch = setTimeout(() => { if (btn.isConnected) btn.textContent = old; }, 1700); } async function share(kind, cfg, btn) { const code = encode(cfg); if (!code) { sfx('locked'); return; } const text = kind === 'link' ? shareUrl(code) : pretty(code), ok = await copyText(text); sfx('confirm'); if (ok) { flash(btn, 'TERSALIN ✓'); say(kind === 'link' ? 'Tautan tantangan disalin. Kirim ke temanmu!' : 'Kode ' + pretty(code) + ' disalin. Kirim ke temanmu!'); } } function cycleRule() { const i = RULES.findIndex(r => r.id === selected), n = RULES[(i + 1) % RULES.length]; selected = n.id; sfx('move'); say(n.id === 'none' ? 'Aturan khusus dimatikan.' : n.name.toUpperCase() + ': ' + n.desc); } function addEntry(root) { const slot = root.querySelector('.home-top'); if (!slot || slot.querySelector('.ch-entry')) return; const b = document.createElement('button'); b.type = 'button'; b.className = 'ch-entry'; b.setAttribute('aria-label', 'Masukkan kode tantangan dari teman'); b.innerHTML = 'KODE TANTANGAN'; b.onclick = () => { sfx('confirm'); open(); }; slot.append(b); } function toolsHtml(cfg) { const rule = ruleOf(selected), code = encode({ ...cfg, rule: rule.id }), pc = pretty(code); return `${esc(pc)}` + `` + `` + ``; } function addArenaTools(root, ctx) { const host = root.querySelector('.match-rules'); if (!host || !ctx || ctx.mode !== 'versus' || ctx.p2 || ctx.runKind || !encode({ ...ctx, rule: 'none' })) return; const cfg = { player: ctx.player, enemy: ctx.enemy, stage: ctx.stage, level: ctx.level }; const head = host.querySelector(':scope > b'), sub = host.querySelector(':scope > span'), was = [head?.textContent, sub?.textContent]; // "FIRST TO 2 WINS / BEST OF 3 ROUNDS" const box = document.createElement('div'); box.className = 'ch-tools'; box.setAttribute('role', 'group'); box.setAttribute('aria-label', 'Kode tantangan'); host.append(box); const paint = () => { box.innerHTML = toolsHtml(cfg); const one = ruleOf(selected).rounds === 1; // teks ronde mengikuti aturan satu ronde if (head) head.textContent = one ? 'SATU RONDE' : was[0]; if (sub) sub.textContent = one ? 'SATU RONDE MENENTUKAN · 90 DETIK' : was[1]; }; paint(); box.addEventListener('click', e => { const b = e.target.closest('[data-ch]'); if (!b) return; if (b.dataset.ch === 'rule') { cycleRule(); paint(); box.querySelector('[data-ch=rule]')?.focus(); } else share(b.dataset.ch, { ...cfg, rule: selected }, b); }); } function addResultTools(root, ctx) { const r = ctx?.result, head = root.querySelector('.rs-head'); if (!r || !head || r.run || r.p2 || r.boss) return; const cfg = { player: r.player, enemy: r.enemy, stage: r.stage, level: r.level, rule: last.rule || 'none' }, code = encode(cfg); if (!code) return; const rule = ruleOf(cfg.rule), box = document.createElement('div'), pc = pretty(code); box.className = 'ch-share'; box.setAttribute('role', 'group'); box.setAttribute('aria-label', 'Bagikan tantangan ini'); box.innerHTML = ``; box.addEventListener('click', e => { const b = e.target.closest('[data-ch]'); if (b) share(b.dataset.ch, cfg, b); }); head.append(box); if (rule.id !== 'none') { const k = head.querySelector('.rs-kicker'); if (k) k.textContent += ' · ' + rule.short; } } function addAboutNote(root) { const panel = root.querySelector('.about-panel'); if (!panel || panel.querySelector('.ch-about')) return; const n = document.createElement('small'); n.className = 'ch-about'; n.textContent = 'Semua data permainan (koin, pencapaian, statistik kebiasaan bermain) hanya tersimpan di browser ini dan tidak dikirim ke mana pun.'; const credit = panel.querySelector(':scope > small'); if (credit) credit.after(n); else panel.querySelector('.menu-primary')?.before(n); } // menu.js memanggil ini di akhir tiap render(); ctx = {player, enemy, stage, level, mode, p2, runKind, result}. const decorate = guard((root, screen, ctx) => { if (!root) return; if (screen === 'home') { selected = 'none'; addEntry(root); } else if (screen === 'arena') addArenaTools(root, ctx); else if (screen === 'result') addResultTools(root, ctx); else if (screen === 'about') addAboutNote(root); }); // ---------- kebiasaan bermain ---------- const MODE_NAME = { versus: 'VS Komputer', training: 'Latihan', arcade: 'Arcade', survival: 'Survival', '2p': 'Dua Pemain', challenge: 'Tantangan', daily: 'Tantangan harian' }; const KEY_OK = /^[\w-]{1,40}$/; const cleanMap = m => { const out = {}; if (m && typeof m === 'object' && !Array.isArray(m)) for (const [k, v] of Object.entries(m)) { const n = Math.floor(+v); if (KEY_OK.test(k) && n > 0 && n < 1e9) out[k] = n; } return out; }; function loadHabits() { try { const raw = JSON.parse(store.get(KEY_HABITS) || 'null'); if (raw && typeof raw === 'object') { const h = { v: 1, ch: cleanMap(raw.ch), mode: cleanMap(raw.mode), stage: cleanMap(raw.stage), foe: cleanMap(raw.foe) }; h.n = Math.max(Math.floor(+raw.n) || 0, Object.values(h.mode).reduce((a, b) => a + b, 0)); return h; } } catch (_) { /* data rusak: mulai dari nol */ } return { v: 1, n: 0, ch: {}, mode: {}, stage: {}, foe: {} }; } let hab = loadHabits(); function habitMode(o, challenge) { if (o.mode === 'training') return 'training'; if (challenge) return 'challenge'; const vs = o.vs && typeof o.vs === 'object' ? o.vs : null, tag = String(vs?.label || '').toUpperCase(); if (vs?.p2) return '2p'; if (o.shadow || tag.startsWith('ARCADE')) return 'arcade'; if (tag.startsWith('SURVIVAL')) return 'survival'; return typeof o.kind === 'string' && Object.hasOwn(MODE_NAME, o.kind) ? o.kind : 'versus'; // o.kind: mode baru bisa menandai dirinya } // Dipanggil begin() untuk tiap pertandingan yang benar-benar dimulai. Bayangan (lawan = salinan pemain) tidak dihitung sebagai karakter lawan. function recordHabits(o) { const mode = o.mode === undefined ? 'versus' : o.mode, ch = !!(o.codeRule && typeof o.codeRule === 'object' && mode === 'versus' && !(o.vs && o.vs.p2) && !o.shadow); const bump = (m, k) => { if (typeof k === 'string' && KEY_OK.test(k)) m[k] = (m[k] || 0) + 1; }; hab.n++; bump(hab.ch, o.player); bump(hab.mode, habitMode(o, ch)); bump(hab.stage, o.stage); if (!o.shadow) bump(hab.foe, o.enemy); store.set(KEY_HABITS, JSON.stringify(hab)); } const track = guard(recordHabits); // Pilihan teratas satu kelompok: jumlah terbanyak, seri menurut nama kunci (supaya tidak berubah-ubah); yang sudah tidak ada di game dilewati. function topOf(map, known, nameOf) { let best = null; for (const [k, n] of Object.entries(map)) { if (!known(k)) continue; if (!best || n > best.n || (n === best.n && k < best.k)) best = { k, n }; } return best ? { name: nameOf(best.k), n: best.n, pct: hab.n ? Math.round(100 * best.n / hab.n) : 0 } : null; } const habitSummary = () => ({ n: hab.n, ch: topOf(hab.ch, hasFighter, fighterName), mode: topOf(hab.mode, k => Object.hasOwn(MODE_NAME, k), k => MODE_NAME[k]), stage: topOf(hab.stage, hasStage, stageName), foe: topOf(hab.foe, hasFighter, fighterName) }); function habitsHtml() { const s = habitSummary(), tile = (name, v) => `
${name}
${v ? `${esc(v.name)}${v.n}× · ${v.pct}%` : '—'}
`; return '

Kebiasaan bermainmu

' + (s.n ? `
${tile('Karakter favorit', s.ch)}${tile('Mode favorit', s.mode)}${tile('Arena favorit', s.stage)}${tile('Lawan paling sering', s.foe)}
` + `

Dihitung dari ${s.n} pertandingan yang kamu mulai. Hanya tersimpan di browser ini dan tidak dikirim ke mana pun.

` : '

Belum ada data. Mainkan beberapa pertandingan, lalu kebiasaanmu muncul di sini. Hanya tersimpan di browser ini.

') + '

'; } function clearHabits() { hab = { v: 1, n: 0, ch: {}, mode: {}, stage: {}, foe: {} }; store.del(KEY_HABITS); } // Tab Koleksi dibuat ulang oleh meta.js setiap render; bagian ini disisipkan sesudahnya (sebelum daftar pencapaian) tanpa mengubah meta.js. function injectBook(ov) { const panel = ov.querySelector('.meta-panel'), on = panel?.querySelector('nav .mt.on'); if (!on || on.dataset.tab !== 'book') return; const body = panel.querySelector('.meta-body'); if (!body || body.querySelector('.hb')) return; const sec = document.createElement('section'); sec.className = 'hb'; sec.innerHTML = habitsHtml(); const anchor = [...body.querySelectorAll('h3')].find(h => /^Pencapaian/i.test(h.textContent.trim())); if (anchor) anchor.before(sec); else body.append(sec); } function onBookClick(e, ov) { if (!e.target.closest?.('[data-hb-clear]')) return; if (!hab.n) { say('Belum ada statistik yang tersimpan.'); return; } if (!confirm('Hapus statistik kebiasaan bermain? Koin, skin, dan pencapaianmu tidak ikut terhapus.')) return; clearHabits(); const sec = ov.querySelector('.hb'); if (sec) sec.innerHTML = habitsHtml(); say('Statistik kebiasaan bermain dihapus.'); } function watchMeta() { const hook = ov => { if (ov._chWatch) return; ov._chWatch = true; const run = () => { try { injectBook(ov); } catch (e) { console.error(e); } }; new MutationObserver(run).observe(ov, { childList: true, subtree: true }); ov.addEventListener('click', e => onBookClick(e, ov)); run(); }; const found = document.getElementById('meta-overlay'); if (found) { hook(found); return; } const mo = new MutationObserver(() => { const ov = document.getElementById('meta-overlay'); if (ov) { mo.disconnect(); hook(ov); } }); mo.observe(document.body, { childList: true }); // meta.js membuat hamparan ini saat pertama kali dibuka } try { watchMeta(); } catch (e) { console.error(e); } window.Challenge = { encode: guard(cfg => encode(cfg), null), decode: guard(raw => decode(raw), fail('error', 'Kode tidak bisa dibaca.')), pretty, shareUrl, copyText, RULES: RULES.map(r => ({ id: r.id, name: r.name, short: r.short, desc: r.desc })), ruleOf, open, start: guard(start, false), pending, decorate, begin, tune, roundStart, roundEnd, finish, hud, get noBlock() { return !!cur()?.noBlock; }, get rule() { return selected; }, set rule(id) { selected = ruleOf(id).id; }, get active() { return active ? { ...active } : null; }, state: () => ({ selected, active: active ? { ...active } : null, last: { ...last } }) }; window.Habits = { data: () => JSON.parse(JSON.stringify(hab)), summary: habitSummary, html: habitsHtml, record: track, clear: clearHabits, KEY: KEY_HABITS }; })();