fighting-konoha/daily.js

365 lines
28 KiB
JavaScript

/* Tantangan harian dan hadiah harian (RENCANA 7.1 dan 7.2). Gaya: daily.css. Dimuat paling akhir di index.html, sesudah meta.js.
Tanggal hanya dipakai memilih aturan harian dan menentukan hadiah; di dalam simulasi pertandingan tidak ada Date maupun Math.random.
7.1 Tantangan harian: tiap tanggal lokal (YYYY-MM-DD) punya satu tantangan yang sama untuk semua pemain: aturan, lawan, arena, dan tingkat dipilih dari hash
tanggal (hash % jumlah pilihan; aturan juga digeser bila kebetulan sama dengan aturan kemarin). Pemain memilih petarungnya sendiri di kartu tantangan.
Tombol kecil HARIAN di pojok atas menu utama membuka kartu (nama aturan, deskripsi, lawan, arena, tingkat, hadiah, streak, pilihan petarung). Tantangan
dimainkan sebagai satu pertandingan versus biasa lewat __game.startMatch({..., daily: tanggal}); MAIN LAGI di layar hasil mengulang tantangan yang sama.
Hadiah: menang = 150 koin + 25 per hari beruntun (maksimal +150), sekali per tanggal; kalah boleh coba lagi. Streak = hari beruntun yang selesai; terputus
bila ada hari terlewat. Simpanan di Meta.save.daily = { last, streak, best, total, greet, fest, pick } (ikut kode simpanan; simpanan lama tanpa kunci ini
terbaca kosong); Meta.addCoins (juga satu-satunya cara memicu simpan) dipanggil sesudah isinya diubah.
7.2 Hadiah hari pertama: sekali per tanggal saat menu utama pertama tampil (+25 koin, toast), ditambah kejutan tanggal tertentu sekali per tahun (FEST).
Sepuluh aturan (RULES). Yang tidak butuh kait khusus diterapkan lewat pengubah sisi (sideMods) dan BALANCE yang diberikan game.js ke arm():
Darah satu (HP kedua pihak 20), Lawan cepat (jalan dan pulih cooldown lawan naik), Tanpa blok (peluang blok CPU 0), Raksasa (HP lawan 1,5x + gambar lebih besar).
Sisanya lewat kait kecil di game.js yang semuanya berbentuk window.Daily?.xxx (tanpa modul ini atau tanpa tantangan aktif semuanya no-op):
startMatch arm(options.daily, {sideMods, balance, versus}) mengaktifkan atau mematikan aturan; dipanggil di setiap startMatch
jump, startAttack, cast, startCommand, startThrow, useShopMove deny(jenis) Tanpa lompat dan Hanya ultimate
press('l') deny('guard') Tanpa blok
step cooldowns(hero.cooldowns) Hanya ultimate (ultimate selalu siap); slip(vx lama, vx baru) Lantai licin
updateDummy slip(...) untuk lawan
hitDummy hit(damage, counter, kombo, waktu mesin) Kombo wajib dan Counter saja
heroGuard (parry berhasil) parry(waktu mesin)
drawDummy foeScale (Raksasa)
Satu ronde: MatchRules.create dan MatchRules.tick dibungkus di sini (match.toWin = 1; pemenang ditetapkan di akhir ronde pertama), HUD game.js membaca match.toWin.
Bungkus lain: VS.show (baris aturan di layar VS), Meta.award (hadiah di rincian koin layar hasil), klik MAIN LAGI (mengulang tantangan yang sama).
Uji manual: ?daily=YYYY-MM-DD memajukan atau memundurkan tanggal untuk seluruh modul (aturan, hadiah, streak); di konsol Daily.setDate('2026-08-17'),
Daily.setDate(null) (kembali ke tanggal asli), Daily.table(14), Daily.status(), Daily.startup(), Daily.resetState(), Daily.open(), Daily.start('megachan'). */
(() => {
'use strict';
// ---- angka ----
const REWARD = 150, REWARD_STEP = 25, REWARD_STEPS = 6, GREET = 25; // hadiah tantangan 150 + 25 per hari beruntun (maksimal +150); hadiah hari pertama main
const HP_DARAH = 20, HP_RAKSASA = 1.5, SKALA_RAKSASA = 1.18, LICIN = .22; // HP Darah satu; pengali HP dan skala gambar Raksasa; bagian percepatan yang tersisa di lantai licin
const COUNTER_OPEN = 2.5, COUNTER_BIASA = .45, COUNTER_BUKA = 1.2; // Counter saja: lama celah terbuka (detik waktu mesin), pengali pukulan biasa dan pukulan di celah
const LAWAN_JALAN = 1.4, LAWAN_CD = 1.35; // Lawan cepat: pengali jalan dan kecepatan pulih cooldown
const LEVEL = { easy: 'MUDAH', medium: 'SEDANG', hard: 'SULIT', excellent: 'SANGAT SULIT' };
const ALL3 = ['easy', 'medium', 'hard'], EASY2 = ['easy', 'medium'];
// levels = tingkat yang boleh dipilih tanggal; aturan yang menyulitkan pemain hanya memakai tingkat ringan.
const RULES = [
{ id: 'darah', name: 'DARAH SATU', vs: 'Kedua pihak cuma punya 20 HP', levels: EASY2, desc: 'Kedua petarung cuma punya 20 HP. Satu serangan parah dan semuanya selesai, jadi jangan kedip.' },
{ id: 'ultimate', name: 'HANYA ULTIMATE', vs: 'Pukulan dan skill mati, ultimate selalu siap', levels: ALL3, desc: 'Pukulan dan skill mogok kerja. Cuma ultimate yang mau masuk, dan dia selalu siap.' },
{ id: 'blok', name: 'TANPA BLOK', vs: 'Blok dan parry mati untuk semua', levels: ALL3, desc: 'Tombol blok dan parry macet, buat kamu dan lawan. Pertahanan terbaik adalah kaki.' },
{ id: 'cepat', name: 'LAWAN CEPAT', vs: 'Lawan lebih cepat, skill cepat pulih', levels: EASY2, desc: 'Lawanmu habis minum kopi tiga gelas: jalannya kencang dan skillnya cepat pulih.' },
{ id: 'licin', name: 'LANTAI LICIN', vs: 'Semua petarung meluncur di lantai', levels: ALL3, desc: 'Lantai baru dipel. Semua petarung meluncur dan susah berhenti tepat waktu.' },
{ id: 'lompat', name: 'TANPA LOMPAT', vs: 'Kamu tidak bisa lompat', levels: ALL3, desc: 'Kakimu terpaku ke lantai. Lompat dan lompat ganda tidak bisa dipakai.' },
{ id: 'kombo', name: 'KOMBO WAJIB', vs: 'Damage naik di tiap pukulan kombo', levels: ALL3, desc: 'Pukulan tunggal cuma menggelitik. Damage naik di setiap pukulan kombo, jadi rangkai terus.' },
{ id: 'ronde', name: 'SATU RONDE', vs: 'Satu ronde menentukan pemenang', levels: ALL3, desc: 'Tidak ada ronde ulang: satu ronde, satu pemenang. Kalau seri, rondenya diulang.' },
{ id: 'counter', name: 'COUNTER SAJA', vs: 'Damage penuh hanya setelah COUNTER atau PARRY', levels: EASY2, desc: 'Pukulan biasa cuma menggores. Buka celah dengan COUNTER atau PARRY, lalu sikat selama celahnya terbuka.' },
{ id: 'raksasa', name: 'RAKSASA', vs: 'Lawan raksasa dengan HP 1,5 kali', levels: EASY2, desc: 'Lawan membesar dan HP-nya 1,5 kali lipat. Sabar, atur jarak, dan jangan panik.' }
];
const DENY = { ultimate: ['attack', 'skill1', 'skill2', 'command', 'throw', 'shop'], lompat: ['jump'], blok: ['guard'] };
// Kejutan tanggal (bulan-hari): sekali per tahun. Hanya hari netral; teks ringan.
const FEST = {
'01-01': { coins: 100, text: 'SELAMAT TAHUN BARU! Semoga kombomu panjang dan dompetmu tebal.' },
'03-14': { coins: 50, text: 'HARI PI! Hari ini semua serba bulat: pukulan, kombo, dan koinmu.' },
'08-08': { coins: 50, text: 'HARI KUCING SEDUNIA! Meong. Satu koin jatuh dari meja, kamu yang pungut.' },
'08-17': { coins: 100, text: 'DIRGAHAYU INDONESIA! Selamat 17-an, semoga pukulanmu sekencang lomba balap karung.' }
};
const QUIPS = ['Absen dulu, baru berantem.', 'Koin receh, semangat gede.', 'Pemanasan gratis dari arena.', 'Selamat datang kembali, jagoan.', 'Satu hari, satu hadiah. Adil, kan?', 'Arena kangen kamu.'];
const $ = s => document.querySelector(s);
const esc = t => String(t ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const toast = (m, k) => { try { window.Meta?.toast?.(m, k); } catch (_) { /* toast hanya hiasan */ } };
// ---- tanggal: hanya untuk memilih aturan dan hadiah ----
const DAY = 86400000, pad = n => String(n).padStart(2, '0'), ISO = /^(\d{4})-(\d{2})-(\d{2})$/;
// Nomor hari dari 'YYYY-MM-DD' lewat UTC (selisih hari tidak terpengaruh zona waktu atau DST); null bila bukan tanggal yang sah.
function dayNum(iso) {
const m = ISO.exec(String(iso)); if (!m) return null;
const t = Date.UTC(+m[1], +m[2] - 1, +m[3]), d = new Date(t);
return d.getUTCFullYear() === +m[1] && d.getUTCMonth() === +m[2] - 1 && d.getUTCDate() === +m[3] ? Math.round(t / DAY) : null;
}
const isoOf = n => new Date(n * DAY).toISOString().slice(0, 10);
let override = null; // tanggal uji dari ?daily=YYYY-MM-DD atau Daily.setDate(...)
function today() { if (override) return override; const d = new Date(); return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()); }
function dateText(iso) {
const n = dayNum(iso); if (n == null) return String(iso);
try { return new Intl.DateTimeFormat('id-ID', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' }).format(new Date(n * DAY)); } catch (_) { return String(iso); }
}
// ---- pemilihan tantangan dari tanggal ----
// FNV-1a 32 bit dengan pengacakan akhir (murmur), jadi tanggal yang berurutan tidak menghasilkan indeks yang berpola.
function hash(s) {
let h = 0x811c9dc5;
for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 0x01000193);
h ^= h >>> 16; h = Math.imul(h, 0x85ebca6b); h ^= h >>> 13; h = Math.imul(h, 0xc2b2ae35); h ^= h >>> 16;
return h >>> 0;
}
const idxOf = (iso, salt, n) => hash(salt + ':' + iso) % n;
// Aturan hari ini; bila kebetulan sama dengan indeks mentah kemarin, geser satu supaya hari berturut-turut jarang sama.
function ruleIdx(iso) {
const n = RULES.length, r = idxOf(iso, 'rule', n), dn = dayNum(iso);
return dn != null && r === idxOf(isoOf(dn - 1), 'rule', n) ? (r + 1) % n : r;
}
const roster = () => { const a = Object.keys(window.FIGHTERS || {}); if (window.Browo && window.BROWO_MANIFEST && !a.includes('browo')) a.push('browo'); return a.sort(); };
const fname = id => window.FIGHTERS?.[id]?.name || (id === 'browo' ? 'BRO WO' : String(id).toUpperCase());
const portrait = id => `assets/${id}/ui/portrait.webp`;
function challenge(iso = today()) {
if (dayNum(iso) == null) return null;
const rule = RULES[ruleIdx(iso)], ids = roster(), stages = Object.keys(window.MatchRules?.stages || {}).sort();
if (!ids.length || !stages.length) return null;
return { date: iso, rule, foe: ids[idxOf(iso, 'foe', ids.length)], stage: stages[idxOf(iso, 'stage', stages.length)], level: rule.levels[idxOf(iso, 'level', rule.levels.length)] };
}
// ---- simpanan: Meta.save.daily ----
const okIso = v => typeof v === 'string' && dayNum(v) != null, cnt = v => (Number.isFinite(v) && v > 0 ? Math.floor(v) : 0);
function view() { // pembacaan aman: kunci hilang atau rusak dibaca kosong
const d = window.Meta?.save?.daily, o = d && typeof d === 'object' ? d : {};
return { last: okIso(o.last) ? o.last : null, streak: cnt(o.streak), best: cnt(o.best), total: cnt(o.total), greet: okIso(o.greet) ? o.greet : null, pick: typeof o.pick === 'string' ? o.pick : null, fest: o.fest && typeof o.fest === 'object' ? o.fest : {} };
}
function store() { const s = window.Meta?.save; if (!s) return null; if (!s.daily || typeof s.daily !== 'object') s.daily = {}; return s.daily; }
const persist = () => { try { window.Meta?.addCoins?.(0); } catch (_) { /* tanpa simpanan */ } }; // addCoins menyimpan seluruh Meta.save
const rewardFor = streak => REWARD + Math.min(Math.max(streak - 1, 0), REWARD_STEPS) * REWARD_STEP;
// streak yang masih hidup: selesai hari ini, atau selesai kemarin (tinggal diselesaikan hari ini); selain itu terputus.
function status(iso = today()) {
const v = view(), dn = dayNum(iso), done = v.last === iso, alive = done || (dn != null && v.last === isoOf(dn - 1)), streak = alive ? v.streak : 0;
return { date: iso, done, streak, best: Math.max(v.best, streak), total: v.total, reward: rewardFor(streak + 1) };
}
// Hadiah tantangan untuk tanggal iso; null bila sudah diambil. Sekali per tanggal.
function claim(iso) {
const d = store(); if (!d || !okIso(iso)) return null;
const v = view(); if (v.last === iso) return null;
const streak = v.last === isoOf(dayNum(iso) - 1) ? v.streak + 1 : 1, coins = rewardFor(streak);
d.last = iso; d.streak = streak; d.best = Math.max(v.best, streak); d.total = v.total + 1;
window.Meta.addCoins(coins);
return { coins, streak, best: d.best };
}
// 7.2: hadiah hari pertama bermain tiap tanggal, dan kejutan tanggal tertentu sekali per tahun. Aman dipanggil berulang (hanya membayar yang belum diambil).
function startup(iso = today()) {
const d = store(); if (!d || !okIso(iso) || !window.Meta?.addCoins) return null;
const got = { greet: 0, fest: 0 };
if (view().greet !== iso) {
d.greet = iso; got.greet = GREET; window.Meta.addCoins(GREET);
toast(`HADIAH HARI INI +${GREET} koin · ${QUIPS[hash('quip:' + iso) % QUIPS.length]}`, 'coin');
}
const key = iso.slice(5), year = iso.slice(0, 4), f = FEST[key];
if (f && view().fest[key] !== year) {
if (!d.fest || typeof d.fest !== 'object') d.fest = {};
d.fest[key] = year; got.fest = f.coins; window.Meta.addCoins(f.coins);
setTimeout(() => toast(`${f.text} +${f.coins} koin`), got.greet ? 1100 : 0);
}
return got;
}
// ---- aturan di mesin (kait dipanggil game.js) ----
let cur = null, redo = null, again = false, baseHp = null, openUntil = -1;
// cur = tantangan yang sedang dimainkan; redo = tantangan terakhir (untuk MAIN LAGI di layar hasil); again = MAIN LAGI baru ditekan.
// flag = tanggal 'YYYY-MM-DD' (atau true = hari ini) bila startMatch dimulai dari kartu tantangan. Dipanggil di setiap startMatch, jadi pertandingan biasa
// otomatis mematikan aturan dan mengembalikan HP pemain.
function arm(flag, ctx) { // galat di sini tidak boleh menghalangi startMatch: tantangan dimatikan dan pertandingan jalan seperti biasa
try { armRun(flag, ctx); } catch (e) { console.error(e); cur = redo = null; }
}
function armRun(flag, ctx) {
const c = ctx || {};
let date = typeof flag === 'string' ? flag : flag ? today() : null;
if (!date && again && redo) date = redo.date;
again = false; openUntil = -1;
if (c.balance) { if (baseHp == null) baseHp = c.balance.heroMax; c.balance.heroMax = baseHp; } // setShadowFoe sudah mengembalikan HP lawan
cur = redo = date && c.versus ? challenge(date) : null;
paintTag();
if (!cur) return;
const id = cur.rule.id, B = c.balance, E = c.sideMods?.enemy;
if (id === 'darah' && B) { B.heroMax = HP_DARAH; B.dummyMax = HP_DARAH; }
else if (id === 'raksasa' && B) B.dummyMax = Math.round(B.dummyMax * HP_RAKSASA);
else if (id === 'cepat' && E) { E.walk = (E.walk ?? 1) * LAWAN_JALAN; E.cd = (E.cd ?? 1) * LAWAN_CD; }
else if (id === 'blok' && E) E.parry = 0; // peluang blok CPU = dasar x parry
}
// true bila aksi pemain dilarang aturan hari ini (dan penanda aturan berkedip sebagai umpan balik).
const deny = kind => { const no = !!cur && !!DENY[cur.rule.id]?.includes(kind); if (no) nudge(); return no; };
// Damage pukulan pemain ke lawan (dipanggil sesudah bonus counter bawaan). combo = jumlah pukulan yang sudah kena sebelum ini; t = waktu mesin.
function hit(d, counter, combo, t) {
const id = cur?.rule.id; if ((id !== 'kombo' && id !== 'counter') || !(d > 0)) return d;
if (id === 'kombo') d *= Math.min(2, .6 + .2 * combo);
else { if (counter) openUntil = t + COUNTER_OPEN; d *= t < openUntil ? COUNTER_BUKA : COUNTER_BIASA; }
return Math.max(1, Math.round(d));
}
const parry = t => { if (cur?.rule.id === 'counter') openUntil = t + COUNTER_OPEN; };
const cooldowns = cd => { if (cur?.rule.id === 'ultimate' && cd) cd.ultimate = 0; };
// Lantai licin: perubahan kecepatan mendatar per langkah hanya sebagian kecil dari biasanya, jadi percepatan dan gesekan sama-sama melemah.
const slip = (prev, next) => (cur?.rule.id === 'licin' ? prev + (next - prev) * LICIN : next);
// Satu ronde dan pembungkus lain dipasang sekali; semuanya no-op bila tidak ada tantangan aktif.
function wrapRules() {
const R = window.MatchRules; if (!R || R.__daily || typeof R.create !== 'function' || typeof R.tick !== 'function') return;
R.__daily = true; const create = R.create, tick = R.tick;
R.create = function () { const m = create.apply(this, arguments); if (m && m.mode === 'versus' && cur?.rule.id === 'ronde') m.toWin = 1; return m; };
R.tick = function (m) {
const ev = tick.apply(this, arguments);
// Satu ronde: pemenang ronde pertama menang pertandingan (seri tetap mengulang ronde). Ditetapkan sebelum game.js membaca match.winner.
if (m?.toWin === 1 && !m.winner) for (const e of ev) if (e.type === 'round-end' && (e.winner === 'player' || e.winner === 'enemy')) m.winner = e.winner;
return ev;
};
}
function wrapVS() {
const V = window.VS; if (!V || V.__daily || typeof V.show !== 'function') return;
V.__daily = true; const show = V.show;
V.show = function (info, onDone) {
const c = cur && info && !info.shadow ? cur : null;
const ok = show.call(this, c ? { ...info, label: 'TANTANGAN HARIAN' } : info, onDone);
if (ok && c) try { decorateVS(c); } catch (e) { console.error(e); }
return ok;
};
}
function wrapMeta() {
const M = window.Meta; if (!M || M.__daily || typeof M.award !== 'function') return;
M.__daily = true; const award = M.award;
M.award = function (r) { const h = award.apply(this, arguments); try { finish(r); } catch (e) { console.error(e); } return h; };
}
// Pertandingan tantangan selesai: menang = hadiah (sekali per tanggal) yang masuk rincian koin layar hasil lewat Meta.lastAward.
function finish(r) {
const c = cur; if (!c || !r) return;
r.daily = { id: c.rule.id, date: c.date };
if (r.winner !== 'player') return;
const got = claim(c.date); if (!got) return;
const aw = window.Meta.lastAward;
if (aw && Array.isArray(aw.lines)) { aw.lines.push({ key: 'daily', label: 'Tantangan harian', amount: got.coins, note: got.streak > 1 ? 'Beruntun ' + got.streak + ' hari' : c.rule.name }); aw.gain += got.coins; }
if (!(window.ResultView && window.FrontEnd?.showResult)) toast(`TANTANGAN HARIAN SELESAI +${got.coins} koin (beruntun ${got.streak} hari)`, 'coin');
}
// MAIN LAGI di layar hasil memanggil startMatch tanpa penanda; klik itu (fase tangkap, sebelum penangan tombol) menandai bahwa yang diulang adalah tantangan terakhir.
// startMatch berjalan di klik yang sama; penanda dicabut sebentar kemudian supaya rematch yang gagal tidak bocor ke pertandingan biasa berikutnya.
let againT = 0;
document.addEventListener('click', e => { if (redo && e.target.closest?.('[data-cmd="rematch"]')) { again = true; clearTimeout(againT); againT = setTimeout(() => { again = false; }, 600); } }, true);
// ---- penanda aturan di arena, baris aturan di layar VS ----
let tag = null, nudgeT = 0;
function paintTag() {
const b = document.body; if (!b) return;
if (!cur) { tag?.remove(); tag = null; delete b.dataset.dailyRule; return; }
b.dataset.dailyRule = cur.rule.id;
const host = $('#game-shell'); if (!host) return;
if (!tag?.isConnected) { tag = document.createElement('div'); tag.id = 'daily-tag'; tag.setAttribute('aria-hidden', 'true'); host.append(tag); }
tag.textContent = '◆ HARIAN · ' + cur.rule.name;
}
function nudge() { if (!tag) return; tag.classList.add('hit'); clearTimeout(nudgeT); nudgeT = setTimeout(() => tag?.classList.remove('hit'), 450); }
function decorateVS(c) {
const el = $('#vs-screen'); if (!el) return;
el.classList.add('has-daily');
const d = document.createElement('div'); d.className = 'vs-daily';
d.innerHTML = `<b>ATURAN HARI INI</b><strong>${esc(c.rule.name)}</strong><span>${esc(c.rule.vs)}</span>`;
el.append(d);
}
// ---- tombol di menu utama dan kartu tantangan ----
const root = $('#front-menu');
let dlg = null, pick = null, started = null, startT = 0;
// Petarung pemain: pilihan di kartu, lalu petarung terakhir yang dimainkan di sesi ini, lalu pilihan tersimpan; tidak boleh sama dengan lawan hari ini.
function chosen(ch, want) {
const ids = roster(), ok = id => id && ids.includes(id) && id !== ch.foe;
if (ok(want)) return want; if (ok(pick)) return pick;
let snap = null; try { snap = window.__game?.snapshot?.().selectedCharacter; } catch (_) { /* tanpa snapshot */ }
if (ok(snap)) return snap; const s = view().pick; if (ok(s)) return s;
return ids.find(id => id !== ch.foe) || ids[0];
}
function go(who) {
const ch = challenge(), G = window.__game; if (!ch || !G?.startMatch) return false;
const me = chosen(ch, who); pick = me;
const d = store(); if (d) d.pick = me;
try { window.Meta?.cancelRun?.(); } catch (_) { /* tanpa rangkaian aktif */ }
let ok = false;
try { ok = G.startMatch({ player: me, enemy: ch.foe, stage: ch.stage, level: ch.level, mode: 'versus', daily: ch.date, vs: { label: 'TANTANGAN HARIAN' } }); } catch (e) { console.error(e); }
if (!ok) { toast('Aset belum siap. Tunggu sebentar lalu coba lagi.'); return false; }
persist(); closeCard(); $('#arena')?.focus({ preventScroll: true });
return true;
}
function cardHtml(ch, st, who) {
const R = ch.rule, ids = roster(), stage = window.MatchRules?.stages?.[ch.stage]?.name || ch.stage;
const fact = (k, v, small) => `<div class="dl-f"><dt>${k}</dt><dd>${v}${small ? `<small>${small}</small>` : ''}</dd></div>`;
const chips = ids.map(id => `<button type="button" class="dl-chip" data-pick="${esc(id)}" aria-pressed="${id === who}"${id === ch.foe ? ' disabled title="Lawan hari ini"' : ''}><img src="${esc(portrait(id))}" alt=""><span>${esc(fname(id))}</span></button>`).join('');
return `<div class="dialog-kicker">KONOHA FIGHTING / TANTANGAN HARIAN</div>`
+ `<div class="dl-head"><h2 id="daily-title">${esc(R.name)}</h2><span class="dl-state${st.done ? ' ok' : ''}">${st.done ? 'SELESAI ✓' : 'BELUM SELESAI'}</span></div>`
+ `<p class="dl-desc">${esc(R.desc)}</p>`
+ `<dl class="dl-facts">`
+ fact('LAWAN', `<img src="${esc(portrait(ch.foe))}" alt="">${esc(fname(ch.foe))}`)
+ fact('ARENA', esc(stage)) + fact('TINGKAT', LEVEL[ch.level] || '')
+ fact('HADIAH', st.done ? 'SUDAH DIAMBIL' : `◆ ${st.reward}`, st.done ? `besok ◆ ${st.reward}` : st.streak ? `hari ke-${st.streak + 1} beruntun` : 'hari pertama')
+ fact('STREAK', st.streak ? `${st.streak} HARI` : '—', st.streak ? `terbaik ${st.best}${st.done ? '' : ', selesaikan hari ini agar lanjut'}` : 'belum ada')
+ `</dl><div class="dl-pick"><span>PILIH PETARUNGMU</span><div class="dl-chips" role="group" aria-label="Pilih petarung">${chips}</div></div>`
+ `<div class="dialog-buttons"><button type="button" class="dl-go" data-go>${st.done ? 'MAIN LAGI (TANPA HADIAH)' : 'MULAI TANTANGAN'}</button><button type="button" data-close>TUTUP</button></div>`
+ `<small class="dl-note">${esc(dateText(ch.date))}${override ? ' · TANGGAL UJI' : ''} · aturan baru tiap hari · hadiah sekali sehari</small>`;
}
function paintCard() {
const ch = challenge(); if (!ch || !dlg) return;
pick = chosen(ch, pick);
dlg.innerHTML = cardHtml(ch, status(ch.date), pick);
}
function onCardClick(e) {
if (e.target === dlg) { const r = dlg.getBoundingClientRect(); if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) closeCard(); return; } // klik di luar kartu
const b = e.target.closest('button'); if (!b) return;
if (b.dataset.pick) { pick = b.dataset.pick; paintCard(); [...dlg.querySelectorAll('[data-pick]')].find(x => x.dataset.pick === pick)?.focus(); }
else if (b.hasAttribute('data-go')) go();
else if (b.hasAttribute('data-close')) closeCard();
}
// game.js menelan Esc (preventDefault) sehingga penutupan bawaan dialog tidak jalan. Pendengar fase tangkap di window (dipasang sekali, hanya bekerja selama kartu terbuka)
// menangani Esc dan panah kiri/kanan (pindah antarpilihan petarung) dan tidak meneruskannya ke game.js, bahkan bila fokus sedang tidak di dalam kartu.
function onCardKey(e) {
if (!dlg?.open) return;
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeCard(); return; }
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
const chips = [...dlg.querySelectorAll('[data-pick]:not(:disabled)')]; if (!chips.length) return;
e.preventDefault(); e.stopPropagation();
const i = chips.indexOf(document.activeElement);
chips[i < 0 ? 0 : (i + (e.key === 'ArrowLeft' ? -1 : 1) + chips.length) % chips.length].focus();
}
function openCard() {
if (!challenge()) { toast('Tantangan belum siap. Tunggu sebentar lalu coba lagi.'); return false; }
if (!dlg) {
dlg = document.createElement('dialog'); dlg.id = 'daily-dialog'; dlg.className = 'daily-dialog'; dlg.setAttribute('aria-labelledby', 'daily-title');
dlg.addEventListener('click', onCardClick);
dlg.addEventListener('close', () => { if (root && !root.hidden) root.querySelector('.daily-entry')?.focus({ preventScroll: true }); });
document.body.append(dlg);
}
paintCard();
if (!dlg.open) dlg.showModal();
dlg.querySelector('[data-go]')?.focus();
return true;
}
function closeCard() { if (dlg?.open) dlg.close(); }
function decorate() {
const slot = root.querySelector('.home-top-right') || root.querySelector('.home-top'), ch = challenge(); if (!slot || !ch) return;
const st = status(ch.date); let b = slot.querySelector('.daily-entry');
if (!b) {
b = document.createElement('button'); b.type = 'button'; b.className = 'daily-entry';
b.addEventListener('click', () => { try { window.__game?.menuSound?.('confirm'); } catch (_) { /* tanpa suara */ } openCard(); });
slot.prepend(b);
}
b.classList.toggle('done', st.done); b.title = override ? 'Tanggal uji: ' + override : '';
b.setAttribute('aria-label', `Tantangan harian: ${ch.rule.name}. ${st.done ? 'Sudah selesai.' : 'Belum selesai.'}${st.streak ? ' Beruntun ' + st.streak + ' hari.' : ''} Buka kartu tantangan.`);
b.innerHTML = `<i aria-hidden="true">◆</i><span class="de-t">HARIAN</span><span class="de-n">${esc(ch.rule.name)}</span>${st.done ? '<b class="de-ok">✓</b>' : '<b>BARU</b>'}${st.streak > 1 ? `<em>${st.streak} HARI</em>` : ''}`;
}
// Hadiah hari pertama dibayar sedikit sesudah menu utama tampil, dan hanya bila menu utama masih tampil saat itu (pertandingan yang langsung dimulai
// tidak diganggu; hadiah menunggu menu utama berikutnya). Sekali per tanggal per muatan halaman; sisanya dijaga simpanan.
function maybeStartup() {
if (started === today() || startT) return;
startT = setTimeout(() => {
startT = 0;
try { if (root.hidden || root.dataset.screen !== 'home') return; started = today(); startup(started); } catch (e) { console.error(e); }
}, 700);
}
function sync() { // dipanggil tiap menu utama digambar ulang (menu.js mengganti isi #front-menu) dan saat tab kembali terlihat
try { if (!root || root.hidden || root.dataset.screen !== 'home') return; decorate(); maybeStartup(); } catch (e) { console.error(e); }
}
// ---- uji manual ----
function setDate(iso) {
if (iso == null || iso === '') override = null; else if (dayNum(iso) != null) override = String(iso); else return false;
started = null; sync(); startup(today());
return today();
}
function resetState() { const s = window.Meta?.save; if (!s) return false; delete s.daily; persist(); started = null; sync(); return true; }
function table(n = 14, from = today()) {
const dn = dayNum(from); if (dn == null) return [];
return Array.from({ length: Math.max(1, Math.min(120, n | 0)) }, (_, i) => { const c = challenge(isoOf(dn + i)); return c && { date: c.date, rule: c.rule.id, foe: c.foe, stage: c.stage, level: c.level }; });
}
try { const q = new URLSearchParams(location.search).get('daily'); if (q && dayNum(q) != null) override = q; } catch (_) { /* tanpa parameter */ }
wrapRules(); wrapVS(); wrapMeta();
window.Daily = {
rules: RULES, today, challenge, status, setDate, resetState, table, startup, open: openCard, close: closeCard, start: go,
arm, deny, hit, parry, cooldowns, slip,
get foeScale() { return cur?.rule.id === 'raksasa' ? SKALA_RAKSASA : 1; },
get cur() { return cur; }, get active() { return !!cur; }, get override() { return override; }
};
window.addEventListener('keydown', onCardKey, true);
if (root) { new MutationObserver(sync).observe(root, { childList: true }); sync(); }
document.addEventListener('visibilitychange', () => { if (!document.hidden) sync(); });
})();