diff --git a/challenge.css b/challenge.css new file mode 100644 index 0000000..8195965 --- /dev/null +++ b/challenge.css @@ -0,0 +1,68 @@ +/* Kode tantangan dan kebiasaan bermain (challenge.js, RENCANA 7.5 dan 7.6). Tinggi jendela terendah yang didukung 520 px (ponsel lanskap 844x390 juga dijaga): + tombol-tombol kecil memakai ruang yang sudah ada (absolut di bilah atas, atau di kolom kanan yang lebih rendah dari kolom kirinya), sehingga tinggi layar tidak bertambah. */ + +/* ---- Menu utama: tombol KODE TANTANGAN di sebelah TUTORIAL (pojok kiri atas), di luar alur bilah .home-top ---- */ +#front-menu .home-top{position:relative} +#front-menu .ch-entry{position:absolute;left:4.5%;top:50%;z-index:1;display:inline-flex;align-items:center;gap:8px;min-height:26px;padding:3px 14px 3px 10px;background:#102b37c4;border:1px solid #67e4dd66;border-left:3px solid #67e4dd;color:#f6eedb;font:700 13px/1.1 Rajdhani,Arial,sans-serif;letter-spacing:2.5px;cursor:pointer;transform:translateY(-50%) skewX(-7deg);transition:background .14s,border-color .14s,transform .14s} +#front-menu .tut-entry~.ch-entry{left:calc(4.5% + 142px)} +#front-menu .ch-entry>*{transform:skewX(7deg)} +#front-menu .ch-entry i{font-style:normal;font-size:12px;color:#67e4dd} +#front-menu .ch-entry:hover,#front-menu .ch-entry:focus-visible{background:#9be9e2;color:#102734;border-color:#d8fffb;transform:translateY(-50%) skewX(-7deg) translateX(4px)} +#front-menu .ch-entry:hover i,#front-menu .ch-entry:focus-visible i{color:#102734} +@media(max-width:560px){#front-menu .ch-entry{font-size:11px;letter-spacing:1.5px;padding:3px 9px 3px 7px}#front-menu .tut-entry~.ch-entry{left:calc(4.5% + 112px)}} +html.touch #front-menu .ch-entry{min-height:30px} + +/* ---- Layar arena: baris ATURAN / SALIN KODE / TAUTAN di kolom kanan .match-rules (kolom kiri berisi tombol tingkat dan lebih tinggi, jadi tinggi baris tidak naik) ---- */ +#front-menu .ch-tools{display:flex;align-items:center;justify-content:flex-end;flex-wrap:wrap;gap:5px;margin-top:6px;letter-spacing:0;text-align:left} +#front-menu .ch-code{display:none;padding:4px 8px;font:700 13px/1 Rajdhani,Arial,sans-serif;letter-spacing:2px;color:#cfe9e2;background:#07151d99;border:1px dashed #9fd3c455;user-select:all} +@media(min-width:900px){#front-menu .ch-code{display:inline-block}} +#front-menu .ch-btn{margin:0;padding:4px 9px;font:700 11px/1.15 Rajdhani,Arial,sans-serif;letter-spacing:1.4px;color:#e6ecea;background:#13303d;border:1px solid #7dada177;cursor:pointer;white-space:nowrap} +#front-menu .ch-btn small{display:inline;margin:0 5px 0 0;font-size:9px;letter-spacing:1px;color:#9fb8bc} +#front-menu .ch-btn:hover,#front-menu .ch-btn:focus-visible{background:#e6c78f;color:#102734;border-color:#fff0c8} +#front-menu .ch-btn:hover small,#front-menu .ch-btn:focus-visible small{color:#35505a} +#front-menu .ch-btn.on{border-color:#e6c78f;color:#f3d796;background:#2a3b3a} +#front-menu .ch-btn.on:hover,#front-menu .ch-btn.on:focus-visible{color:#102734;background:#e6c78f} +@media(max-height:420px){#front-menu .ch-tools{margin-top:3px;gap:4px}#front-menu .ch-btn{padding:2px 7px;font-size:10px}#front-menu .ch-btn small{font-size:8px}#front-menu .ch-code{padding:2px 6px;font-size:11px}} +@media(pointer:coarse) and (min-height:421px){#front-menu .ch-btn{min-height:26px}} + +/* ---- Layar hasil: SALIN KODE / TAUTAN di pojok kanan atas kepala hasil (tombol STATISTIK ada di pojok kanan bawah) ---- */ +#front-menu .rs-head{position:relative} +#front-menu .ch-share{position:absolute;right:0;top:0;z-index:2;display:flex;gap:6px;animation:rs-up .5s .9s both} +@media(max-width:700px){#front-menu .ch-share .ch-btn[data-ch=link]{display:none}} + +/* ---- HUD: penanda tantangan tepat di bawah kolom tengah (jam), ikut posisi bilah atas di semua ukuran ---- */ +.ch-badge{position:absolute;left:50%;top:100%;transform:translateX(-50%);margin-top:6px;padding:2px 10px;white-space:nowrap;font:700 10px/1.35 Rajdhani,Arial,sans-serif;letter-spacing:1.8px;color:#102734;background:#e6c78f;border:1px solid #fff0c8;box-shadow:0 2px 6px #0007} +@media(max-width:950px){.ch-badge{font-size:9px;letter-spacing:1.2px;padding:1px 7px;margin-top:4px}} +@media(max-height:500px) and (min-width:620px){.ch-badge{margin-top:3px}} +@media(max-width:620px){.ch-badge{font-size:8px;letter-spacing:.8px;padding:1px 5px}} + +/* ---- Dialog Masukkan kode / undangan ---- */ +#ch-dialog{width:min(470px,calc(100% - 24px));padding:22px 26px 20px} +#ch-dialog h2{font-size:30px;margin:6px 0 4px} +#ch-dialog .ch-lead{margin:4px 0 12px;font-size:15px;line-height:1.4} +#ch-dialog .ch-field{display:flex;align-items:center;gap:12px} +#ch-dialog .ch-field>span{flex:none;font-size:12px;font-weight:700;letter-spacing:2px;color:var(--gold)} +#ch-input{flex:1;min-width:0;padding:8px 12px;font:700 26px/1.1 Rajdhani,Arial,sans-serif;letter-spacing:5px;text-transform:uppercase;color:#fff8e8;background:#0a1620;border:1px solid #8da8b1aa;border-radius:0} +#ch-input::placeholder{color:#5f7782;letter-spacing:5px} +#ch-input:focus{border-color:var(--gold)} +#ch-dialog .ch-preview{min-height:76px;margin:12px 0 4px;padding:9px 12px;font-size:15px;line-height:1.4;color:#b9cbd0;background:#ffffff0a;border-left:3px solid #ffffff2e} +#ch-dialog .ch-preview.ok{border-left-color:#67e4dd;color:#e8f6f1} +#ch-dialog .ch-preview.err{border-left-color:#ff9a7a;color:#ffc7b8} +#ch-dialog .ch-vs{display:flex;align-items:baseline;flex-wrap:wrap;gap:4px 10px;font-size:22px;line-height:1.15} +#ch-dialog .ch-vs b,#ch-dialog .ch-line b{color:#fff0c8} +#ch-dialog .ch-vs i{font-style:normal;font-size:13px;letter-spacing:2px;color:var(--gold)} +#ch-dialog .ch-actions{display:flex;align-items:center;gap:12px;margin-top:10px} +#ch-dialog .ch-actions .primary-button{width:auto;flex:1;margin:0} +#ch-dialog .ch-actions .primary-button:disabled{background:#6d6e66;color:#2a3338;cursor:not-allowed} +#ch-dialog .ch-actions .text-button{margin:0;white-space:nowrap} +@media(max-height:480px){#ch-dialog{padding:14px 22px 12px}#ch-dialog h2{font-size:24px;margin:2px 0}#ch-dialog .ch-lead{display:none}#ch-input{font-size:22px;padding:6px 10px}#ch-dialog .ch-preview{min-height:60px;margin-top:8px;padding:6px 10px;font-size:14px}#ch-dialog .ch-vs{font-size:19px}} + +/* ---- Koleksi: "Kebiasaan bermainmu" (disisipkan challenge.js sebelum daftar pencapaian) ---- */ +.hb-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:8px;margin:12px 0 0} +.hb-grid>div{min-width:0;padding:8px 12px;background:#101c26;border:1px solid #ffffff1c;border-radius:8px} +.hb-grid dt{font-size:13px;letter-spacing:.06em;color:#9fb3c4;text-transform:uppercase} +.hb-grid dd{margin:2px 0 0;font:700 20px/1.15 Rajdhani,Arial,sans-serif;color:#f6eedb;overflow-wrap:anywhere} +.hb-grid dd small{display:block;margin-top:1px;font-size:14px;font-weight:600;color:#8fa3b3} + +/* ---- About: catatan privasi satu baris ---- */ +.about-panel>small.ch-about{margin-top:3px;line-height:1.45;color:#8fb0b6} diff --git a/challenge.js b/challenge.js new file mode 100644 index 0000000..9f0e83d --- /dev/null +++ b/challenge.js @@ -0,0 +1,432 @@ +/* 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(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) => `