// Tahap 2 (brand): sisa AETHER CLASH, kutipan layar loading (2.7), daftar "Yang baru" (2.8), latar menu kolase (2.5). // node tests/harness.mjs tests/t2-brand.mjs // BRAND_SHOTS=folder simpan screenshot (menu, About, toast, boot) untuk dilihat // BRAND_ONLY=regex hanya jalankan bagian yang namanya cocok (text|quote|log|menu|flow|docs) // Jendela 1280x520 dipakai sengaja: tampilan harus muat di tinggi 520 px. import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; export const options = { viewport: { width: 1280, height: 520 } }; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); export default async ({ page, game, url, errors }) => { const checks = [], ok = (name, pass, info) => checks.push({ name, pass: !!pass, info }); const only = process.env.BRAND_ONLY ? new RegExp(process.env.BRAND_ONLY) : null, want = n => !only || only.test(n); const shots = process.env.BRAND_SHOTS; if (shots) fs.mkdirSync(shots, { recursive: true }); const shot = async (p, name) => { if (shots) await p.screenshot({ path: path.join(shots, name + '.png') }); }; const wait = ms => new Promise(r => setTimeout(r, ms)); const BAD = /aether|mecha|demi-?human|\barco\b|\bfenr\b|helios|wolf ranger|seismic/i; const read = f => fs.readFileSync(path.join(ROOT, f), 'utf8'); const reload = async (p, query = '?touch=0') => { await p.goto(url + query); await p.waitForFunction(() => window.__game && window.__game.snapshot().ready && window.FrontEnd && document.querySelector('#front-menu[data-screen]'), null, { timeout: 60000 }); }; const setStore = (p, obj) => p.evaluate(o => { localStorage.clear(); for (const [k, v] of Object.entries(o)) if (v != null) localStorage.setItem(k, v); }, obj); // teks dan atribut (aria-label, alt, title) yang benar-benar tampil atau terbaca pembaca layar; dialog (Pengaturan, Jeda) ikut dihitung // walau sedang tertutup. HUD yang sedang disembunyikan menu (visibility:hidden) tidak ikut: isinya diisi ulang JS saat pertandingan mulai. const textDump = p => p.evaluate(() => { const out = [], w = document.createTreeWalker(document.documentElement, NodeFilter.SHOW_ELEMENT); for (let n = w.currentNode; n; n = w.nextNode()) { if (/^(SCRIPT|STYLE|NOSCRIPT|HEAD|TITLE|META|LINK)$/.test(n.tagName)) continue; if (!(n.checkVisibility({ visibilityProperty: true }) || n.closest('dialog'))) continue; for (const a of ['aria-label', 'alt', 'title', 'placeholder']) if (n.getAttribute(a)) out.push(`[${n.tagName.toLowerCase()} ${a}] ${n.getAttribute(a)}`); for (const c of n.childNodes) if (c.nodeType === 3 && c.textContent.trim()) out.push(c.textContent.trim()); } out.push('[title] ' + document.title); return out; }); const hits = lines => lines.filter(t => BAD.test(t)); // ---------- 1. sisa AETHER CLASH tidak terlihat pemain ---------- if (want('text')) { await game(() => window.FrontEnd.home()); const home = await textDump(page); const homeHits = hits(home); ok('teks: halaman dasar (index.html, HUD, dialog) bersih dari AETHER/ARCO/FENR/Mecha/Demi-Human', homeHits.length === 0, homeHits); const screens = {}; await game(() => document.querySelector('[data-cmd="versus"]').click()); screens.pilihPemain = hits(await textDump(page)); await game(() => document.querySelector('[data-cmd="back"]').click()); await game(() => document.querySelector('[data-cmd="training"]').click()); await page.click('[data-fighter="browo"]'); await wait(1100); await page.click('[data-fighter="megachan"]'); await wait(1100); screens.arena = hits(await textDump(page)); const tags = await game(() => Object.values(window.MatchRules.stages).map(s => s.tag)); ok('teks: semua tag arena berbahasa Indonesia tanpa "aether"', tags.every(t => t && !BAD.test(t) && !/^(The|Above|Where|Under)\b/.test(t)), tags); await game(() => window.FrontEnd.home()); await game(() => document.querySelector('[data-cmd="about"]').click()); const about = await textDump(page); const credit = about.filter(t => BAD.test(t)), allCredit = about.join(' '); ok('teks: About memuat kredit AETHER CLASH oleh Bang Tutorial (satu-satunya yang boleh menyebut nama itu)', /AETHER CLASH oleh Bang Tutorial/.test(allCredit) && credit.every(t => /AETHER CLASH|Bang Tutorial/.test(t)), credit); ok('teks: About memuat tautan repo asli dan tutorial', await game(() => [...document.querySelectorAll('.about-panel a')].map(a => a.href)).then(h => h.some(x => /github\.com\/bangtutorial\/aether-clash/.test(x)) && h.some(x => /youtube\.com/.test(x)))); await game(() => window.FrontEnd.home()); // HUD pertandingan sungguhan: nama petarung asli, tanpa sisa placeholder lama await game(() => { const g = window.__game; g.startMatch({ player: 'sonofsolo', enemy: 'anice', stage: 'bellora', level: 'easy', mode: 'versus' }); g.manual = true; g.advance(.5); g.draw(); }); const hud = await textDump(page); screens.hudPertandingan = hits(hud); ok('teks: HUD pertandingan menampilkan nama dan jurus asli (SON OF SOLO MAN vs A NICE)', hud.includes('SON OF SOLO MAN') && hud.includes('A NICE'), hud.filter(t => /SOLO|NICE/.test(t))); await game(() => { window.__game.manual = false; window.FrontEnd.home(); }); // layar hasil dan overlay Meta await game(() => window.FrontEnd.showResult({ winner: 'player', playerWins: 2, enemyWins: 1, player: 'browo', enemy: 'megachan', stage: 'bellora', level: 'hard' })); screens.hasil = hits(await textDump(page)); await game(() => window.FrontEnd.home()); for (const tab of ['shop', 'locker', 'book']) { await game(t => window.Meta.open(t), tab); screens['meta-' + tab] = hits(await textDump(page)); await game(() => window.Meta.close()); } await game(() => window.FrontEnd.home()); ok('teks: layar pilih, arena, hasil, Toko, Karakter, Koleksi bersih', Object.values(screens).every(a => a.length === 0), screens); ok('teks: di jendela 520 px tidak ada galat halaman selama menjelajah layar', errors.length === 0, errors); // teks sumber yang dibaca pemain (di luar About) const src = ['index.html', 'mobile.html', 'menu.js'].map(f => [f, read(f)]); const srcHits = []; for (const [f, t] of src) { const stripped = t.replace(/(src|href)="[^"]*"/g, '').replace(/assets\/[\w./-]+/g, '').replace(/aether\.fullscreenHint|AETHER_PRELOAD|aetherFullscreen|aetherActivate|aether-activate|aether-fullscreen/g, '').replace(/Berbasis AETHER CLASH oleh Bang Tutorial/g, '').replace(/AETHER CLASH/g, m => (f === 'menu.js' ? '' : m)); for (const m of stripped.matchAll(new RegExp(BAD.source, 'gi'))) srcHits.push(f + ': ' + stripped.slice(Math.max(0, m.index - 30), m.index + 30).replace(/\s+/g, ' ')); } ok('teks: sumber index.html, mobile.html, menu.js tanpa sisa nama lama (selain jalur aset dan kunci internal)', srcHits.length === 0, srcHits.slice(0, 8)); } // ---------- 2. kutipan layar loading ---------- if (want('quote')) { const q = await game(() => window.LOADING_QUOTES); const evil = /tuhan|allah|agama|surga|neraka|kafir|haram|gereja|masjid|ulama|pendeta|korupsi|tuduh|bodoh|goblok|tolol|bego|babi|anjing|mati\b/i; ok('quote: data LOADING_QUOTES ada, minimal 20 kutipan unik', Array.isArray(q) && q.length >= 20 && new Set(q).size === q.length, q?.length); ok('quote: tiap kutipan teks pendek (<=90 huruf) dan lolos pedoman (tanpa agama/tuduhan/hinaan)', q.every(s => typeof s === 'string' && s.length > 10 && s.length <= 90 && !evil.test(s)), q.filter(s => s.length > 90 || evil.test(s))); // kartu boot di halaman baru: dipaksa ?quote=N, dan acak tanpa pengulangan berturut-turut const browser = page.context().browser(), ctx = await browser.newContext({ viewport: { width: 1280, height: 520 }, serviceWorkers: 'block' }); const p2 = await ctx.newPage(), errs2 = []; p2.on('pageerror', e => errs2.push(e.message)); await p2.route('**/assets/**', async r => { await wait(250); r.continue(); }); await p2.goto(url + '?touch=0"e=5', { waitUntil: 'domcontentloaded' }); await wait(700); const boot = await p2.evaluate(() => ({ boot: document.querySelector('#boot .boot-quote')?.textContent, load: document.querySelector('#load-state .load-quote')?.textContent, vis: !!document.getElementById('boot') })); ok('quote: kartu boot dan #load-state menampilkan kutipan ke-5', boot.boot === q[5] && boot.load === q[5], boot); await shot(p2, 'boot'); await p2.close(); const seen = []; for (let i = 0; i < 9; i++) { const p3 = await ctx.newPage(); await p3.route('**/assets/**', r => r.abort()); await p3.goto(url + '?touch=0', { waitUntil: 'domcontentloaded' }); seen.push(await p3.evaluate(() => window.Brand.quote)); await p3.close(); } ok('quote: tiap pemuatan menampilkan kutipan dari daftar', seen.every(s => q.includes(s)), seen); ok('quote: dua pemuatan berturut-turut tidak pernah kutipan sama', seen.every((s, i) => i === 0 || s !== seen[i - 1]), seen); ok('quote: kutipan berganti (lebih dari 3 jenis dalam 9 pemuatan)', new Set(seen).size > 3, [...new Set(seen)].length); await ctx.close(); // localStorage menolak akses: tetap tampil, tanpa galat const ctx2 = await browser.newContext({ viewport: { width: 1280, height: 520 }, serviceWorkers: 'block' }); await ctx2.addInitScript(() => { const boom = () => { throw new Error('storage blocked'); }; Storage.prototype.getItem = boom; Storage.prototype.setItem = boom; }); const p4 = await ctx2.newPage(), errs4 = []; p4.on('pageerror', e => errs4.push(e.message)); await p4.goto(url + '?touch=0'); await p4.waitForFunction(() => window.__game && window.__game.snapshot().ready, null, { timeout: 60000 }); ok('quote: storage diblokir, game tetap siap tanpa galat dan kutipan tetap ada', errs4.length === 0 && !!(await p4.evaluate(() => window.Brand.quote)), errs4); await ctx2.close(); // content/loading.js gagal dimuat: boot tidak rusak const ctx3 = await browser.newContext({ viewport: { width: 1280, height: 520 }, serviceWorkers: 'block' }); const p5 = await ctx3.newPage(), errs5 = []; p5.on('pageerror', e => errs5.push(e.message)); await p5.route('**/content/loading.js', r => r.abort()); await p5.goto(url + '?touch=0'); await p5.waitForFunction(() => window.__game && window.__game.snapshot().ready, null, { timeout: 60000 }); ok('quote: tanpa content/loading.js game tetap siap, tanpa galat', errs5.length === 0, errs5); await ctx3.close(); } // ---------- 3. daftar Yang baru ---------- if (want('log')) { const cl = await game(() => window.CHANGELOG); const iso = /^\d{4}-\d{2}-\d{2}$/, ver = v => String(v).split('.').map(Number); const desc = cl.every((e, i) => i === 0 || (ver(cl[i - 1].version) > ver(e.version) || (ver(cl[i - 1].version)[0] === ver(e.version)[0] && ver(cl[i - 1].version)[1] > ver(e.version)[1]))); ok('log: CHANGELOG berisi >= 3 versi, tiap versi punya tanggal ISO, judul, dan item berbahasa Indonesia', cl.length >= 3 && cl.every(e => e.version && iso.test(e.date) && e.title && Array.isArray(e.items) && e.items.length && e.items.every(s => typeof s === 'string' && s.length > 15)), cl.map(e => e.version)); ok('log: versi menurun (terbaru paling atas) dan tanggal tidak naik', desc && cl.every((e, i) => i === 0 || cl[i - 1].date >= e.date), cl.map(e => e.version + ' ' + e.date)); const all = JSON.stringify(cl).toLowerCase(); ok('log: mencakup fitur yang sudah ada (efek jurus, rasa kena, blok/parry/lempar, Arcade/Survival/dua pemain, toko)', ['efek jurus', 'rasa kena', 'blok', 'parry', 'lempar', 'arcade', 'survival', 'dua pemain', 'toko'].every(k => all.includes(k)), null); const latest = cl[0].version; const ver0 = await game(() => window.Brand.version); ok('log: Brand.version = versi teratas', ver0 === latest, ver0); // pemain baru: tanpa simpanan dan tanpa catatan versi -> tanpa toast, versi langsung dicatat await setStore(page, {}); await reload(page); await wait(1700); let st = await game(() => ({ toast: !!document.querySelector('.brand-toast'), seen: localStorage.getItem('konoha.changelog.seen') })); ok('log: pemain baru tidak diberi toast, versi tercatat', !st.toast && st.seen === latest, st); // pemain lama (ada simpanan) tanpa catatan versi -> toast sekali await setStore(page, { 'konoha.save.v1': JSON.stringify({ v: 1, coins: 50, earned: 50 }) }); await reload(page); await page.waitForSelector('.brand-toast', { timeout: 5000 }).catch(() => {}); st = await game(() => ({ toast: document.querySelector('.brand-toast')?.innerText, seen: localStorage.getItem('konoha.changelog.seen'), ab: document.querySelector('.brand-toast')?.getAttribute('role') })); ok('log: pemain lama melihat toast "Ada yang baru" memuat nomor versi', /Ada yang baru/.test(st.toast || '') && (st.toast || '').includes(latest) && st.ab === 'status', st); ok('log: versi dicatat begitu toast tampil (satu kali)', st.seen === latest, st); await shot(page, 'toast-home'); // reload: tidak muncul lagi await reload(page); await wait(1700); ok('log: setelah dimuat ulang toast tidak muncul lagi', !(await game(() => !!document.querySelector('.brand-toast'))), null); // versi berubah: lama 0.2 -> toast + lencana BARU di About untuk versi yang lebih baru saja await setStore(page, { 'konoha.save.v1': JSON.stringify({ v: 1, coins: 5, earned: 5 }), 'konoha.changelog.seen': '0.2' }); await reload(page); await page.waitForSelector('.brand-toast', { timeout: 5000 }).catch(() => {}); ok('log: versi berubah (0.2 -> terbaru) memunculkan toast', await game(() => !!document.querySelector('.brand-toast')), null); await page.click('.brand-toast .bt-look'); await page.waitForSelector('#front-menu[data-screen="about"] .whatsnew-panel', { timeout: 4000 }); const ab = await game(() => ({ entries: document.querySelectorAll('.wn-entry').length, badges: [...document.querySelectorAll('.wn-entry')].filter(e => e.querySelector('.wn-badge')).map(e => e.querySelector('h3 b').textContent), first: document.querySelector('.wn-entry h3 b').textContent, scroll: !!document.querySelector('.wn-scroll'), label: document.querySelector('.whatsnew-panel').getAttribute('aria-label') })); ok('log: tombol "Lihat" membuka About dengan panel Yang baru (satu entri per versi, terbaru di atas)', ab.entries === cl.length && ab.first === 'v' + latest && ab.label === 'Yang baru', ab); const expectBadges = cl.filter(e => ver(e.version)[1] > 2).map(e => 'v' + e.version); ok('log: lencana BARU hanya pada versi yang belum dilihat', JSON.stringify(ab.badges) === JSON.stringify(expectBadges), { got: ab.badges, expectBadges }); await shot(page, 'about-changelog'); const fit = await game(() => { const m = document.querySelector('#front-menu'), p = document.querySelector('.whatsnew-panel').getBoundingClientRect(), a = document.querySelector('.about-panel').getBoundingClientRect(); return { panelBottom: Math.round(p.bottom), aboutBottom: Math.round(a.bottom), h: innerHeight, scrollH: m.scrollHeight, clientH: m.clientHeight }; }); ok('log: di tinggi 520 px panel About dan Yang baru muat di layar (tanpa menggulung halaman)', fit.panelBottom <= fit.h && fit.aboutBottom <= fit.h && fit.scrollH <= fit.clientH + 1, fit); // tidak ada toast ganda saat kembali ke menu utama await game(() => window.FrontEnd.home()); await wait(1500); ok('log: kembali ke home tidak memunculkan toast lagi', !(await game(() => !!document.querySelector('.brand-toast'))), null); // data hilang: tidak ada galat const ctx = await page.context().browser().newContext({ viewport: { width: 1280, height: 520 }, serviceWorkers: 'block' }); const p6 = await ctx.newPage(), errs6 = []; p6.on('pageerror', e => errs6.push(e.message)); await p6.route('**/content/changelog.js', r => r.abort()); await p6.goto(url + '?touch=0'); await p6.waitForFunction(() => window.__game && window.__game.snapshot().ready && document.querySelector('#front-menu[data-screen]'), null, { timeout: 60000 }); await p6.evaluate(() => document.querySelector('[data-cmd="about"]').click()); await wait(300); ok('log: tanpa content/changelog.js About tetap tampil tanpa panel dan tanpa galat', errs6.length === 0 && (await p6.evaluate(() => !document.querySelector('.whatsnew-panel') && !!document.querySelector('.about-panel'))), errs6); await ctx.close(); await setStore(page, {}); await reload(page); } // ---------- 4. latar menu kolase ---------- if (want('menu')) { await reload(page); await game(() => window.FrontEnd.home()); await wait(500); const m = await game(() => { const art = document.querySelector('.home-art'), slots = [...art.querySelectorAll('.hc-slot img')].map(i => i.getAttribute('src')); const cs = getComputedStyle(art), r = art.querySelector('.hc-slot'), vid = document.querySelectorAll('#front-menu video').length; return { slots, bg: cs.backgroundImage, vid, aria: art.getAttribute('aria-hidden'), anim: getComputedStyle(r).animationName, play: getComputedStyle(r).animationPlayState, imgsLoaded: [...art.querySelectorAll('img')].every(i => i.complete && i.naturalWidth > 0), ids: Object.keys(window.FIGHTERS || {}) }; }); ok('menu: kolase memuat tujuh petarung dari assets/menu/-select.webp', m.slots.length === 7 && m.slots.every(s => /^assets\/menu\/[a-z]+-select\.webp$/.test(s)) && new Set(m.slots).size === 7 && m.imgsLoaded, m); ok('menu: tanpa video dan tanpa gambar latar bergaya game asal', m.vid === 0 && !/home-factions|home-art/.test(m.bg), { vid: m.vid, bg: m.bg }); ok('menu: kolase disembunyikan dari pembaca layar dan bergerak (animasi aktif)', m.aria === 'true' && m.anim !== 'none' && m.play === 'running', m); await shot(page, 'home'); const tag = await game(() => ({ build: document.querySelector('.build-tag').textContent, version: window.Brand.version })); ok('menu: tag di pojok menu utama menampilkan versi game (bukan teks lama VOL. 03)', tag.build.includes('VERSI ' + tag.version) && !/VOL\. 03|PARODY FIGHTER/.test(tag.build), tag); const fitHome = await game(() => { const n = document.querySelector('.home-nav').getBoundingClientRect(), f = document.querySelector('.home-footer').getBoundingClientRect(); return { navBottom: Math.round(n.bottom), footerTop: Math.round(f.top), h: innerHeight }; }); ok('menu: di tinggi 520 px semua tombol menu utama muat di layar', fitHome.navBottom <= fitHome.h, fitHome); // matikan lewat Pengaturan: gerak berhenti, nyalakan lagi: jalan await game(() => document.querySelector('[data-cmd="settings"]').click()); await wait(200); let still = await game(() => ({ cls: document.querySelector('#front-menu').classList.contains('bg-still'), play: getComputedStyle(document.querySelector('.hc-slot')).animationPlayState })); ok('menu: saat Pengaturan terbuka gerak latar berhenti', still.cls && still.play === 'paused', still); await page.click('#close-settings'); await wait(150); still = await game(() => ({ cls: document.querySelector('#front-menu').classList.contains('bg-still'), play: getComputedStyle(document.querySelector('.hc-slot')).animationPlayState })); ok('menu: setelah Pengaturan ditutup gerak latar jalan lagi', !still.cls && still.play === 'running', still); await game(() => document.querySelector('[data-cmd="settings"]').click()); await wait(150); await game(() => { const t = document.querySelector('#menu-video-toggle'); t.checked = false; t.dispatchEvent(new Event('change')); }); await page.click('#close-settings'); await wait(150); still = await game(() => ({ cls: document.querySelector('#front-menu').classList.contains('bg-still'), play: getComputedStyle(document.querySelector('.hc-slot')).animationPlayState })); ok('menu: saklar "Animasi latar menu" mematikan gerak', still.cls && still.play === 'paused', still); await game(() => document.querySelector('[data-cmd="settings"]').click()); await wait(150); await game(() => { const t = document.querySelector('#menu-video-toggle'); t.checked = true; t.dispatchEvent(new Event('change')); }); await page.click('#close-settings'); await wait(150); ok('menu: saklar dinyalakan lagi, gerak jalan', !(await game(() => document.querySelector('#front-menu').classList.contains('bg-still'))), null); const label = await game(() => document.querySelector('#menu-video-toggle').closest('label').innerText); ok('menu: label saklar tidak lagi menyebut video', !/video/i.test(label), label); // prefers-reduced-motion: tidak ada animasi, saklar mati const ctx = await page.context().browser().newContext({ viewport: { width: 1280, height: 520 }, serviceWorkers: 'block', reducedMotion: 'reduce' }); const p7 = await ctx.newPage(), errs7 = []; p7.on('pageerror', e => errs7.push(e.message)); await p7.goto(url + '?touch=0'); await p7.waitForFunction(() => window.__game && window.__game.snapshot().ready && document.querySelector('.home-art .hc-slot'), null, { timeout: 60000 }); await wait(300); const rm = await p7.evaluate(() => ({ anim: getComputedStyle(document.querySelector('.hc-slot')).animationName, stripes: getComputedStyle(document.querySelector('.hc-stripes')).animationName, still: document.querySelector('#front-menu').classList.contains('bg-still') })); ok('menu: prefers-reduced-motion mematikan semua gerak latar', rm.anim === 'none' && rm.stripes === 'none' && rm.still && errs7.length === 0, { rm, errs7 }); await ctx.close(); // layar sentuh: paling banyak lima petarung (hemat memori gambar) const ctx2 = await page.context().browser().newContext({ viewport: { width: 812, height: 375 }, serviceWorkers: 'block', hasTouch: true, isMobile: true }); const p8 = await ctx2.newPage(); await p8.goto(url + '?touch=1&embed=1'); await p8.waitForFunction(() => window.__game && window.__game.snapshot().ready && document.querySelector('.home-art .hc-slot'), null, { timeout: 60000 }); const nt = await p8.evaluate(() => ({ n: document.querySelectorAll('.hc-slot').length, touch: document.documentElement.classList.contains('touch') })); ok('menu: layar sentuh memuat lima petarung di kolase', nt.touch && nt.n === 5, nt); await shot(p8, 'home-phone'); await ctx2.close(); // arena di bawah layar menu tidak ikut: kembali dari About ke home membangun ulang kolase await game(() => document.querySelector('[data-cmd="about"]').click()); await wait(200); const ab = await game(() => document.querySelectorAll('.home-art .hc-slot').length); ok('menu: About juga memakai kolase (tanpa gambar bawaan lama)', ab === 7, ab); } // ---------- 5. alur permainan tetap jalan ---------- if (want('flow')) { await reload(page); await game(() => window.FrontEnd.home()); await page.click('[data-cmd="versus"]'); await page.click('[data-fighter="browo"]'); await wait(1100); await page.click('[data-fighter="gufron"]'); await wait(1100); await page.click('[data-cmd="start"]'); await wait(300); const s = await game(() => { const g = window.__game, sn = g.snapshot(); g.manual = true; g.advance(4); return { menuOpen: sn.menuOpen, mode: sn.match?.mode, hero: Math.round(g.hero.hp), hud: [document.querySelector('#player-name').textContent, document.querySelector('#enemy-name').textContent, document.querySelector('.skill-name').textContent] }; }); ok('flow: VS Komputer lewat menu: HUD memakai nama petarung sebenarnya (bukan placeholder)', !s.menuOpen && s.mode === 'versus' && s.hud[0] === 'BRO WO' && s.hud[1] === 'GUFRON' && !/SERANGAN|LAWAN|PEMAIN/.test(s.hud[0] + s.hud[1]) && s.hud[2] === 'TINJU SAFARI', s); for (const mode of ['training', 'versus']) { const r = await game(({ mode }) => { const g = window.__game; g.startMatch({ player: 'megachan', enemy: 'sonofsolo', stage: 'moonrise', level: 'hard', mode }); g.setAI(true); g.manual = true; for (let i = 0; i < 40; i++) { g.advance(.25); if (i % 4 === 0) { g.press('o'); g.release('o'); } } g.draw(); return { hp: Math.round(g.hero.hp), name: document.querySelector('#enemy-name').textContent }; }, { mode }); ok(`flow: ${mode} berjalan 10 detik tanpa galat`, r.name === 'SON OF SOLO MAN', r); } await game(() => { window.__game.manual = false; window.FrontEnd.home(); }); await game(() => window.Meta?.startRun('arcade', 'browo')); await wait(600); ok('flow: Arcade dimulai dari menu', await game(() => window.__game.snapshot().menuOpen === false), null); await game(() => { window.Meta.cancelRun(); window.FrontEnd.home(); }); await game(() => { const g = window.__game; g.setP2(false); }); } // ---------- 6. dokumen ---------- if (want('docs')) { const readme = read('README.md'), deploy = read('DEPLOY.md'); ok('docs: README tetap mengkredit AETHER CLASH karya Bang Tutorial (dengan tautan repo asli dan lisensi)', /AETHER CLASH/.test(readme) && /Bang Tutorial/.test(readme) && /github\.com\/bangtutorial\/aether-clash/.test(readme) && /CC BY-NC/.test(readme) && /MIT/.test(readme), null); ok('docs: README tidak lagi mengaku sebagai "Mecha vs Demi-Human" / dua belas petarung asal', !/Mecha vs Demi-Human|12 playable|Twelve chibi|\bARCO\b|\bFENR\b/.test(readme), readme.match(/Mecha vs Demi-Human|12 playable|Twelve chibi|\bARCO\b|\bFENR\b/)); ok('docs: DEPLOY.md bertajuk Konoha Fighting', /^# .*Konoha Fighting/m.test(deploy) && !/^# Deploying AETHER CLASH/m.test(deploy), deploy.split('\n')[0]); ok('docs: LICENSE-ASSETS.md tetap memuat kredit', /AETHER CLASH by Bang Tutorial/.test(read('LICENSE-ASSETS.md')), null); } const failed = checks.filter(c => !c.pass); return { passed: checks.length - failed.length, failed: failed.length, failures: failed, all: checks.map(c => (c.pass ? 'OK ' : 'FAIL ') + c.name) }; };