/** * ============================================================ * 좋은 아침, 로스캘리퍼 — 브라우저 콘솔 자동 플레이어 * ============================================================ * 사용법: * 1. 이벤트 페이지를 열고 로그인한다. * https://act.hoyoverse.com/zzz/event/e20260606-warmup-rpxqfd/index.html?lang=ko-kr * 2. F12 → 콘솔 탭을 연다. * 3. 이 파일 내용 전체를 복사해서 붙여넣고 Enter. * 4. 중지하려면: ZZZ.stop() * 5. 현재 상태 확인: ZZZ.status() * ============================================================ */ (function () { 'use strict'; // ───────────────────────────────────────────── // 설정 // ───────────────────────────────────────────── const CFG = { delay: 1200, // 기본 클릭 사이 대기(ms) shortDelay: 400, // 짧은 대기(ms) readDelay: 900, // 스토리 대사 넘김 간격(ms) longDelay: 2500, // 로딩/전환 후 대기(ms) loginTimeout: 120000, // 로그인 대기 최대(ms) maxDialogue: 200, // 대화 클릭 최대 횟수 idleLimit: 12, // 아무 진행 요소도 못 찾았을 때 대기 반복 한도 maxRuns: 300, // 총 런 최대 횟수 (무한루프 방지) maxChoiceDepth: 4, // 탐색할 선택지 경로 최대 깊이 maxChoiceIdx: 4, // 선택지 인덱스 최대값 (0~3 가정) }; // ───────────────────────────────────────────── // CSS 셀렉터 (보고서 §9 기반) // ───────────────────────────────────────────── const SEL = { cookieOk: '.cookie-bar button, .cookie-ok, [class*="cookie"] button', agreeCheck: '.agreement-checkbox, input[type="checkbox"]', startBtn: '.start-game-btn, [class*="start-game"]', loginModal: '.hyv-login-platform, .mihoyo-account-role, [class*="login-platform"]', commonDialog: '.common-dialog, .dialog-layer, [class*="common-dialog"], [class*="dialog-layer"]', commonConfirm: '.confirm-btn, .dialog-btn--confirm, [class*="confirm-btn"], [class*="dialog-btn--confirm"]', mapCard: '.map-card, [class*="map-card"], [class*="chapter-card"], .chapter-container, [class*="chapter-container"], .route-tab, [class*="route-tab"]', storyStart: '.continue-adventure-entry-btn, [class*="continue-adventure-entry-btn"], .action-btn--continue, [class*="action-btn--continue"], .action-btn--restart, [class*="start-from-node"], .storytree-button, [class*="storytree-button"], .guide-btn', startSelected: '.bottom-actions__item--confirm, [class*="bottom-actions__item--confirm"], [class*="storytree-start-from-node"], [class*="start-from-node"], [class*="start_from_node"], [class*="story_tree_start_from_selected"], [class*="map_btn_continue"]', storyTree: '.storytree-wrap, .dialog-storytree, .route-map-content, .route-map-svg, .route-map-wrap, [class*="storytree-wrap"], [class*="route-map"]', storyNode: '.node-normal:not(.node-normal--locked), .node-item:not(.node-normal--locked), [data-node-id]:not([class*="locked"]), [class*="node-item"]:not([class*="locked"]), [class*="node-normal"]:not([class*="locked"])', dialogNext: '.dialog-btn--confirm, .action-btn--continue, .dialog-btn, [class*="gal-next"], [class*="next-btn"], [class*="dialog-btn"], [class*="gal-btn"]', dialogBox: '.dialog-box, .dialog-text, .dialog-ui-layer, .black-cover, [class*="dialog-box"], [class*="dialog-text"], [class*="dialog-ui-layer"], [class*="black-cover"]', storySkip: '[data-sfx-skip], [class*="gal_skip"], [class*="skip"]', choices: '.dialog-option-view, .option-card:not(.option-card--disabled), [class*="option-view"], [class*="dialog-option"], [class*="choice-item"], [class*="option-card"]:not([class*="disabled"])', endingModal: '.ending-modal, [class*="ending-modal"], [class*="ending-dialog"]', endingName: '.ending-name, [class*="ending-name"], [class*="ending-title"]', restartBtn: '.action-btn--restart, [class*="restart"], [class*="retry"], [class*="replay"], [class*="again"]', backBtn: '.back-btn, .action-btn--storytree, [class*="back-btn"], [class*="to-map"], [class*="return-btn"], [class*="storytree"]', rewardBtn: '.reward-btn, [class*="reward-btn"], [class*="task-reward"]', claimAll: '[class*="claim-all"], [class*="receive-all"], [class*="btn-claim-all"]', claimSingle: '[class*="claim"]:not([class*="claimed"]), [class*="receive"]:not([class*="received"])', loading: '.loading, [class*="loading-screen"], [class*="loading-wrap"]', }; // 장소 정보 (보고서 §12, §13) const CHAPTERS = [ { id: 1, code: 'FuzzyWrench', name: '털뭉치 렌치', target: 5, endings: ['조회수가 곧 힘이다', '교육은 일찍부터', '최고의 영업사원', '정비 견습생', '녹초가 돼'] }, { id: 2, code: 'Therter', name: '스포트라이트 극장', target: 5, endings: ['사실… 전 배우예요', '가마니가 돼', '공역순찰국 명탐정', '니트로 퓨엘의 대부', '극장의 왕'] }, { id: 3, code: 'BoomScones', name: '붐스콘', target: 5, endings: ['천국의 미식부', '심리 식이요법', '제빵 견습생', '감「빵」의 신', '빵은 예술이다'] }, { id: 4, code: 'ChicBootique', name: '시크 부티크', target: 5, endings: ['카피 디자이너', '제식 전문가', '핑크 화이트 마녀', '천사의 강림', '패션 트렌드세터'] }, { id: 5, code: 'MainCenter', name: '중추연산국', target: 5, endings: ['커뮤니티 중추', '야근은 싫어', '연산력 상승', '공역순찰국의 명탐정', '중추연산국의 실력자'] }, ]; // ───────────────────────────────────────────── // 내부 상태 // ───────────────────────────────────────────── const STATE = { running: false, runToken: 0, storyTreeTapIndex: 0, collected: {}, // { chapterCode: Set } rewardsDone: false, totalRuns: 0, log: [], }; CHAPTERS.forEach(ch => { STATE.collected[ch.code] = new Set(); }); // ───────────────────────────────────────────── // 유틸리티 // ───────────────────────────────────────────── const sleep = ms => new Promise(r => setTimeout(r, ms)); const isActive = token => STATE.running && STATE.runToken === token; const textOf = el => ( el?.innerText || el?.textContent || el?.value || el?.getAttribute?.('aria-label') || el?.title || '' ).trim(); const isVisible = el => { if (!el) return false; const s = window.getComputedStyle(el); const r = el.getBoundingClientRect(); return s.display !== 'none' && s.visibility !== 'hidden' && s.opacity !== '0' && r.width > 3 && r.height > 3 && r.bottom >= 0 && r.right >= 0 && r.top <= window.innerHeight && r.left <= window.innerWidth; }; const $ = css => { try { return [...document.querySelectorAll(css)].filter(isVisible); } catch (e) { log(`셀렉터 오류: ${css}`); return []; } }; const uniq = list => [...new Set(list.filter(Boolean))]; const log = (msg, ...args) => { const t = new Date().toLocaleTimeString('ko-KR'); const line = `[ZZZ ${t}] ${msg}`; console.log(line, ...args); STATE.log.push(line); }; const click = async (el, label = '') => { try { if (!el) return false; el.scrollIntoView?.({ block: 'center', inline: 'center' }); await sleep(80); const r = el.getBoundingClientRect(); const x = Math.max(1, Math.min(window.innerWidth - 1, r.left + r.width / 2)); const y = Math.max(1, Math.min(window.innerHeight - 1, r.top + r.height / 2)); const txt = label || (textOf(el) || el.className || el.tagName || '?').toString().trim().slice(0, 50); const opts = { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y, screenX: x, screenY: y }; if (window.PointerEvent) { el.dispatchEvent(new PointerEvent('pointerdown', { ...opts, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1 })); } el.dispatchEvent(new MouseEvent('mousedown', { ...opts, button: 0, buttons: 1 })); if (window.TouchEvent && window.Touch) { try { const touch = new Touch({ identifier: 1, target: el, clientX: x, clientY: y, screenX: x, screenY: y }); el.dispatchEvent(new TouchEvent('touchstart', { bubbles: true, cancelable: true, touches: [touch], targetTouches: [touch], changedTouches: [touch] })); el.dispatchEvent(new TouchEvent('touchend', { bubbles: true, cancelable: true, touches: [], targetTouches: [], changedTouches: [touch] })); } catch (_) {} } if (window.PointerEvent) { el.dispatchEvent(new PointerEvent('pointerup', { ...opts, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 0 })); } el.dispatchEvent(new MouseEvent('mouseup', { ...opts, button: 0, buttons: 0 })); el.dispatchEvent(new MouseEvent('click', { ...opts, button: 0, buttons: 0 })); log(`클릭: "${txt}"`); return true; } catch (e) { log(`클릭 실패: ${e.message}`); return false; } }; const tapAt = async (x, y, label = '좌표 탭') => { const cx = Math.max(1, Math.min(window.innerWidth - 1, x)); const cy = Math.max(1, Math.min(window.innerHeight - 1, y)); const el = document.elementFromPoint(cx, cy) || document.body; const opts = { bubbles: true, cancelable: true, view: window, clientX: cx, clientY: cy, screenX: cx, screenY: cy }; if (window.PointerEvent) { el.dispatchEvent(new PointerEvent('pointerdown', { ...opts, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1 })); } el.dispatchEvent(new MouseEvent('mousedown', { ...opts, button: 0, buttons: 1 })); if (window.PointerEvent) { el.dispatchEvent(new PointerEvent('pointerup', { ...opts, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 0 })); } el.dispatchEvent(new MouseEvent('mouseup', { ...opts, button: 0, buttons: 0 })); el.dispatchEvent(new MouseEvent('click', { ...opts, button: 0, buttons: 0 })); log(`클릭: "${label}" (${Math.round(cx)}, ${Math.round(cy)})`); return true; }; const waitLoading = async (timeout = 10000) => { const end = Date.now() + timeout; while (Date.now() < end) { if ($(SEL.loading).length === 0) return; await sleep(400); } }; const bodyText = () => (document.body?.innerText || ''); const interactiveElements = () => uniq([ ...$('button, a, input, [role="button"]'), ...$(SEL.commonConfirm), ...$(SEL.startBtn), ...$(SEL.mapCard), ...$(SEL.storyStart), ...$(SEL.startSelected), ...$(SEL.storyNode), ...$(SEL.dialogNext), ...$(SEL.choices), ...$(SEL.restartBtn), ...$(SEL.backBtn), ...$(SEL.rewardBtn), ]); const hasAny = css => $(css).length > 0; const isNonProgressElement = el => { const t = textOf(el).toLowerCase(); const c = String(el.className || '').toLowerCase(); return t.includes('결말') || t.includes('보상') || t.includes('수령') || t.includes('닫기') || t.includes('취소') || t.includes('공유') || t.includes('목록') || t.includes('뒤로') || t.includes('건너뛰기') || t.includes('ending') || t.includes('reward') || t.includes('claim') || t.includes('close') || t.includes('cancel') || t.includes('share') || t.includes('skip') || c.includes('dialog-btn--ending') || c.includes('ending-btn') || c.includes('story_tree_btn_ending') || c.includes('open-ending') || c.includes('reward-btn') || c.includes('back-btn') || c.includes('close') || c.includes('cancel') || c.includes('skip'); }; const isProgressElement = el => { if (!isVisible(el) || isNonProgressElement(el)) return false; const t = textOf(el).toLowerCase(); const c = String(el.className || '').toLowerCase(); return c.includes('continue') || c.includes('storytree-button') || c.includes('start-from-node') || c.includes('guide-btn') || c.includes('node-item') || c.includes('node-normal') || c.includes('dialog-btn--confirm') || c.includes('gal-next') || c.includes('next-btn') || t.includes('계속') || t.includes('시작') || t.includes('진행') || t.includes('확인') || t.includes('다음') || t.includes('노드'); }; const isStartSelectedElement = el => { if (!isVisible(el) || hasAny(SEL.endingModal)) return false; const t = textOf(el).toLowerCase(); const c = String(el.className || '').toLowerCase(); return hasAny(SEL.storyTree) && !c.includes('share') && !c.includes('cancel') && !t.includes('공유') && !t.includes('취소') && ( t.includes('선택한 노드부터 시작') || t.includes('노드부터 시작') || t.includes('start') || c.includes('bottom-actions__item--confirm') || c.includes('start-from-node') || c.includes('start_from_node') || c.includes('story_tree_start_from_selected') || c.includes('map_btn_continue') ); }; const clickStartFromSelectedNode = async (token = STATE.runToken) => { if (!isActive(token)) return false; const candidates = uniq([ ...$(SEL.startSelected), ...interactiveElements(), ]).filter(isStartSelectedElement); const target = candidates.find(el => textOf(el).includes('선택한 노드부터 시작')) || candidates[0]; if (target) { await click(target, '선택한 노드부터 시작'); await sleep(CFG.longDelay); await waitLoading(); return true; } const selectedNode = document.querySelector('[class*="node-normal--selected"], [class*="node-item--selected"]'); if (!selectedNode || !hasAny(SEL.storyTree)) return false; await tapAt(window.innerWidth * 0.78, window.innerHeight * 0.93, '선택한 노드부터 시작'); await sleep(CFG.longDelay); await waitLoading(); return true; }; const isSkipElement = el => { if (!isVisible(el)) return false; const t = textOf(el).toLowerCase(); const c = String(el.className || '').toLowerCase(); return !c.includes('cancel') && !t.includes('취소') && ( t.includes('건너뛰기') || t.includes('skip') || c.includes('gal_skip') || c.includes('skip') || el.hasAttribute?.('data-sfx-skip') ); }; const confirmSkipIfNeeded = async (token = STATE.runToken) => { if (!isActive(token)) return false; await sleep(CFG.shortDelay); const candidates = uniq([ ...$(SEL.commonConfirm), ...$(SEL.dialogNext), ...interactiveElements(), ]).filter(el => { const t = textOf(el).toLowerCase(); const c = String(el.className || '').toLowerCase(); return isVisible(el) && !c.includes('ending') && !c.includes('cancel') && !t.includes('취소') && ( t.includes('확인') || t.includes('ok') || t.includes('건너뛰기') || t.includes('skip') || c.includes('dialog-btn--confirm') || c.includes('confirm') ); }); const confirm = candidates[0]; if (!confirm) return false; await click(confirm, '건너뛰기 확인'); await sleep(CFG.longDelay); await waitLoading(); return true; }; const clickStorySkip = async (token = STATE.runToken) => { if (!isActive(token) || hasAny(SEL.storyTree) || hasAny(SEL.endingModal)) return false; const skip = uniq([ ...$(SEL.storySkip), ...$('button, a, [role="button"]'), ]).find(isSkipElement); if (!skip) return false; await click(skip, '스토리 건너뛰기'); await confirmSkipIfNeeded(token); await sleep(CFG.longDelay); await waitLoading(); return true; }; const clickDialogueAdvance = async (token = STATE.runToken) => { if (!isActive(token) || hasAny(SEL.storyTree) || hasAny(SEL.endingModal)) return false; const box = $(SEL.dialogBox) .filter(el => !String(el.className || '').toLowerCase().includes('skip')) .sort((a, b) => { const ar = a.getBoundingClientRect(); const br = b.getBoundingClientRect(); return (br.width * br.height) - (ar.width * ar.height); })[0]; if (!box) return false; const r = box.getBoundingClientRect(); await tapAt(r.left + r.width * 0.92, r.top + r.height * 0.78, '스토리 다음'); await sleep(CFG.readDelay); return true; }; const hasClickableProgress = () => ( $(SEL.choices).length > 0 || $(SEL.startSelected).some(isStartSelectedElement) || $(SEL.storyStart).some(isProgressElement) || $(SEL.storyNode).some(isProgressElement) || $(SEL.dialogNext).some(isProgressElement) || hasAny(SEL.endingModal) ); const clickByText = async (needles, label = '텍스트 버튼', within = interactiveElements()) => { const words = needles.map(s => s.toLowerCase()); const target = within.find(el => { const t = textOf(el).toLowerCase(); return t && words.some(w => t.includes(w)); }); if (!target) return false; return click(target, label || textOf(target)); }; const dismissCommonDialogs = async (token = STATE.runToken) => { if (!isActive(token)) return false; let did = false; // 로그인 모달 내부 버튼은 사용자가 직접 처리해야 하므로 건드리지 않는다. if ($(SEL.loginModal).length > 0) return false; const closeCandidates = interactiveElements().filter(el => { const t = textOf(el).toLowerCase(); return t === 'ok' || t === '확인' || t.includes('닫기') || t.includes('계속') || t.includes('넘어가기') || t.includes('continue'); }); for (const el of closeCandidates.slice(0, 2)) { if (!isActive(token)) break; did = await click(el, textOf(el) || '확인') || did; await sleep(CFG.shortDelay); } const startBtns = $(SEL.startBtn); const startBtn = startBtns.find(b => textOf(b).includes('시작') || textOf(b).toLowerCase().includes('start')) || startBtns[0]; if (startBtn && isActive(token)) { did = await click(startBtn, '게임 시작') || did; await sleep(CFG.longDelay); } return did; }; const hasProgressSurface = () => ( hasAny(SEL.mapCard) || hasAny(SEL.storyStart) || hasAny(SEL.startSelected) || hasAny(SEL.storyTree) || hasAny(SEL.storyNode) || hasAny(SEL.dialogNext) || hasAny(SEL.dialogBox) || hasAny(SEL.choices) || hasAny(SEL.endingModal) || hasAny(SEL.rewardBtn) || CHAPTERS.some(ch => bodyText().includes(ch.name)) ); const waitForProgressSurface = async (token = STATE.runToken, timeout = 30000) => { const end = Date.now() + timeout; while (Date.now() < end && isActive(token)) { await waitLoading(3000); if (hasProgressSurface()) return true; await dismissCommonDialogs(token); if (hasProgressSurface()) return true; await sleep(800); } return hasProgressSurface(); }; const mapTapPoint = ch => { // WebGL/Spine 맵에서 DOM 카드가 안 보일 때 쓰는 보수적인 상대 좌표. const points = [ { x: 0.22, y: 0.56 }, { x: 0.38, y: 0.43 }, { x: 0.53, y: 0.60 }, { x: 0.68, y: 0.44 }, { x: 0.82, y: 0.56 }, ]; return points[ch.id - 1] || points[0]; }; const clickStoryEntry = async (token = STATE.runToken) => { if (!isActive(token)) return false; if (await clickStartFromSelectedNode(token)) return true; const entryCandidates = uniq([ ...$(SEL.storyStart), ...$(SEL.dialogNext), ]).filter(isProgressElement); const preferred = entryCandidates.find(el => { const t = textOf(el).toLowerCase(); const c = String(el.className || '').toLowerCase(); return c.includes('continue') || c.includes('storytree-button') || c.includes('guide-btn') || t.includes('계속') || t.includes('시작') || t.includes('진행') || t.includes('확인') || t.includes('노드'); }) || entryCandidates.find(el => !String(el.className || '').toLowerCase().includes('locked')); if (preferred) { await click(preferred, textOf(preferred) || '스토리 진입'); await sleep(CFG.longDelay); await waitLoading(); await clickStartFromSelectedNode(token); return true; } const nodes = $(SEL.storyNode).filter(el => { const c = String(el.className || '').toLowerCase(); return !c.includes('locked') && !c.includes('selected') && !isNonProgressElement(el); }); const node = nodes[0]; if (node) { await click(node, textOf(node) || '스토리 노드 선택'); await sleep(CFG.delay); await waitLoading(); await clickStartFromSelectedNode(token); return true; } if (!preferred) { const surfaces = $(SEL.storyTree) .filter(el => !String(el.className || '').toLowerCase().includes('ending')) .sort((a, b) => { const ar = a.getBoundingClientRect(); const br = b.getBoundingClientRect(); return (br.width * br.height) - (ar.width * ar.height); }); const surface = surfaces[0]; if (!surface || hasAny(SEL.endingModal)) return false; const r = surface.getBoundingClientRect(); if (r.width < 40 || r.height < 40) return false; const beforeText = bodyText().slice(0, 1200); const beforeSelected = document.querySelectorAll('[class*="node-normal--selected"], [class*="node-item--selected"]').length; const points = [ { x: 0.18, y: 0.50 }, { x: 0.32, y: 0.45 }, { x: 0.46, y: 0.55 }, { x: 0.60, y: 0.45 }, { x: 0.74, y: 0.55 }, ]; const p = points[STATE.storyTreeTapIndex++ % points.length]; await tapAt(r.left + r.width * p.x, r.top + r.height * p.y, '스토리트리 노드 후보'); await sleep(CFG.delay); await waitLoading(); await clickStartFromSelectedNode(token); const afterText = bodyText().slice(0, 1200); const afterSelected = document.querySelectorAll('[class*="node-normal--selected"], [class*="node-item--selected"]').length; return afterText !== beforeText || afterSelected !== beforeSelected || hasClickableProgress(); } return false; }; const detectEnding = (chapterCode) => { const ch = CHAPTERS.find(c => c.code === chapterCode); if (!ch) return null; const txt = bodyText(); for (const kw of ch.endings) { if (txt.includes(kw)) return kw; } // ending-name 요소 직접 확인 const nameEl = $(SEL.endingName)[0]; if (nameEl) { const t = nameEl.textContent.trim(); if (t) return t; } return null; }; // ───────────────────────────────────────────── // 선택지 경로 생성 (BFS 순서) // ───────────────────────────────────────────── function* generatePaths(maxDepth, maxIdx) { yield []; // 선택지 없는 경우 for (let depth = 1; depth <= maxDepth; depth++) { // depth개의 선택지를 순서대로 조합 const indices = Array(depth).fill(0); while (true) { yield [...indices]; // 다음 조합 계산 let pos = depth - 1; while (pos >= 0) { indices[pos]++; if (indices[pos] <= maxIdx) break; indices[pos] = 0; pos--; } if (pos < 0) break; } } } // ───────────────────────────────────────────── // 초기 화면 처리 // ───────────────────────────────────────────── const dismissOverlays = async (token = STATE.runToken) => { // 쿠키 OK for (const el of $(SEL.cookieOk)) { if (!isActive(token)) return; const t = textOf(el).toLowerCase(); if (t.includes('ok') || t.includes('확인') || t.includes('동의')) { await click(el, '쿠키 OK'); await sleep(CFG.shortDelay); break; } } // 약관 체크박스 for (const el of $(SEL.agreeCheck)) { if (!isActive(token)) return; if (!el.checked) { await click(el, '약관 동의'); await sleep(CFG.shortDelay); } } // 게임 시작 const startBtns = $(SEL.startBtn); const startBtn = startBtns.find(b => textOf(b).includes('시작') || textOf(b).toLowerCase().includes('start')) || startBtns[0]; if (startBtn && isActive(token)) { await click(startBtn, '게임 시작'); await sleep(CFG.longDelay); } await dismissCommonDialogs(token); }; const waitLogin = async (token = STATE.runToken) => { const modals = $(SEL.loginModal); if (modals.length === 0) { log('로그인 상태 확인됨.'); return true; } log('⚠️ 로그인이 필요합니다. 직접 로그인해 주세요...'); const end = Date.now() + CFG.loginTimeout; while (Date.now() < end && isActive(token)) { if ($(SEL.loginModal).length === 0) { log('✅ 로그인 감지!'); await sleep(CFG.longDelay); await dismissCommonDialogs(token); return true; } await sleep(1000); } log(isActive(token) ? '❌ 로그인 타임아웃.' : '로그인 대기 중지됨.'); return false; }; // ───────────────────────────────────────────── // 장소 진입 // ───────────────────────────────────────────── const enterChapter = async (ch, token = STATE.runToken) => { await waitForProgressSurface(token, 15000); await dismissCommonDialogs(token); for (let attempt = 0; attempt < 3 && isActive(token); attempt++) { const cards = uniq([ ...$(SEL.mapCard), ...interactiveElements().filter(el => { const c = String(el.className || '').toLowerCase(); const t = textOf(el); return c.includes('chapter') || c.includes('map-card') || c.includes('route-tab') || t.includes(ch.name) || t.includes(`0${ch.id}`) || t.includes(String(ch.id)); }), ]); let target = cards.find(c => textOf(c).includes(ch.name)); if (!target) target = cards.find(c => String(c.className || '').toLowerCase().includes(ch.code.toLowerCase())); if (!target && cards.length >= ch.id) target = cards[ch.id - 1]; if (target) { await click(target, `장소: ${ch.name}`); await sleep(CFG.longDelay); await waitLoading(); await dismissCommonDialogs(token); await clickStoryEntry(token); return true; } if (attempt === 0) { const back = $(SEL.backBtn).find(el => !String(el.className || '').includes('hide-ui')); if (back) { await click(back, '맵/스토리트리로'); await sleep(CFG.longDelay); await waitLoading(); continue; } } const p = mapTapPoint(ch); log(`장소 카드가 안 보여 좌표 진입 시도: ${ch.name}`); await tapAt(window.innerWidth * p.x, window.innerHeight * p.y, `장소 좌표: ${ch.name}`); await sleep(CFG.longDelay); await waitLoading(); await dismissCommonDialogs(token); await clickStoryEntry(token); if (hasAny(SEL.dialogNext) || hasAny(SEL.dialogBox) || hasAny(SEL.choices) || hasAny(SEL.storyStart) || hasAny(SEL.startSelected) || hasAny(SEL.storyNode) || hasAny(SEL.endingModal)) { return true; } } log(`❌ 장소 진입 실패: ${ch.name}`); return false; }; // ───────────────────────────────────────────── // 단일 런 (한 번의 스토리 진행) // ───────────────────────────────────────────── const playOneRun = async (ch, choicePath, token = STATE.runToken) => { const pathIter = choicePath[Symbol.iterator](); const actualChoices = []; let dialogueCount = 0; let choiceCount = 0; let idleCount = 0; for (let i = 0; i < CFG.maxDialogue; i++) { if (!isActive(token)) return { ending: null, choices: actualChoices }; await sleep(CFG.shortDelay); // 결말 감지 const ending = detectEnding(ch.code); if (ending) return { ending, choices: actualChoices }; await dismissCommonDialogs(token); // 선택지 const choiceEls = $(SEL.choices).filter(el => { const c = String(el.className || '').toLowerCase(); return !c.includes('disabled') && !c.includes('locked'); }); if (choiceEls.length > 0) { idleCount = 0; const { value: idx, done } = pathIter.next(); const choiceIdx = done ? (choiceEls.length - 1) : Math.min(idx, choiceEls.length - 1); actualChoices.push(choiceIdx); const label = textOf(choiceEls[choiceIdx]).slice(0, 40) || `선택지[${choiceIdx}]`; log(` 🔀 선택 [${choiceIdx}]: "${label}"`); await click(choiceEls[choiceIdx], label); await sleep(CFG.delay + 500); choiceCount++; continue; } if (await clickStorySkip(token)) { idleCount = 0; dialogueCount++; continue; } // 대화 다음 버튼 const nextBtns = $(SEL.dialogNext).filter(el => { const t = textOf(el).toLowerCase(); const c = String(el.className || '').toLowerCase(); return isProgressElement(el) && !c.includes('cancel') && !t.includes('취소') && !t.includes('cancel'); }); if (nextBtns.length > 0) { idleCount = 0; await click(nextBtns[0], '다음'); await sleep(CFG.shortDelay); dialogueCount++; continue; } if (await clickDialogueAdvance(token)) { idleCount = 0; dialogueCount++; continue; } if (await clickStoryEntry(token)) { idleCount = 0; continue; } // 아무것도 없음 — 대기 idleCount++; if (idleCount >= CFG.idleLimit) { log(` ⚠️ 진행 요소를 찾지 못함 (${idleCount}회 대기)`); break; } await sleep(600); } return { ending: null, choices: actualChoices }; }; // ───────────────────────────────────────────── // 재시작 // ───────────────────────────────────────────── const restartRun = async (ch, token = STATE.runToken) => { // 재시작/처음부터 버튼 탐색 await dismissCommonDialogs(token); const candidates = [ ...$(SEL.restartBtn), ...$(SEL.dialogNext), ...$(SEL.backBtn), ]; const restartBtn = candidates.find(el => { const t = textOf(el).toLowerCase(); const c = String(el.className || '').toLowerCase(); return t.includes('다시') || t.includes('처음') || t.includes('시작') || t.includes('retry') || t.includes('restart') || t.includes('again') || c.includes('restart') || c.includes('continue'); }); if (restartBtn && isActive(token)) { await click(restartBtn, '재시작'); await sleep(CFG.longDelay); await waitLoading(); await clickStoryEntry(token); return true; } // 맵으로 돌아가서 재진입 const backBtn = $(SEL.backBtn)[0]; if (backBtn && isActive(token)) { await click(backBtn, '맵으로'); await sleep(CFG.longDelay); return await enterChapter(ch, token); } return await clickStoryEntry(token); }; // ───────────────────────────────────────────── // 챕터 결말 수집 // ───────────────────────────────────────────── const collectEndings = async (ch, token = STATE.runToken) => { const collected = STATE.collected[ch.code]; if (collected.size >= ch.target) { log(`[${ch.name}] 이미 완료됨, 건너뜀.`); return; } log(`\n${'='.repeat(50)}`); log(`[${ch.name}] 결말 수집 시작 (${collected.size}/${ch.target})`); const triedPaths = new Set(); for (const choicePath of generatePaths(CFG.maxChoiceDepth, CFG.maxChoiceIdx)) { if (!isActive(token)) break; if (STATE.totalRuns >= CFG.maxRuns) { log('총 런 한도 도달.'); break; } if (collected.size >= ch.target) break; const key = choicePath.join(','); if (triedPaths.has(key)) continue; triedPaths.add(key); STATE.totalRuns++; log(`[${ch.name}] 런 #${STATE.totalRuns} | 경로: [${choicePath.join(', ')}]`); // 런 실행 const { ending, choices } = await playOneRun(ch, [...choicePath], token); if (ending) { if (!collected.has(ending)) { collected.add(ending); log(` 🎉 새 결말: "${ending}" (${collected.size}/${ch.target})`); } else { log(` ♻️ 기존 결말: "${ending}"`); } } else { log(` ⚠️ 결말 미달성`); } // 재시작 if (collected.size < ch.target) { const ok = await restartRun(ch, token); if (!ok) { log(`재시작 실패 — 장소 재진입 시도`); if (!await enterChapter(ch, token)) break; } } } log(`[${ch.name}] 완료: ${collected.size}/${ch.target} 결말 수집`); }; // ───────────────────────────────────────────── // 보상 수령 // ───────────────────────────────────────────── const claimRewards = async (token = STATE.runToken) => { if (STATE.rewardsDone) { log('보상 이미 수령 완료.'); return; } log('\n보상 수령 시작...'); if (!isActive(token)) return; // 보상 탭/버튼 열기 const rewardBtns = $(SEL.rewardBtn); if (rewardBtns.length && isActive(token)) { await click(rewardBtns[0], '보상 탭'); await sleep(CFG.longDelay); } // 모두 수령 const claimAll = $(SEL.claimAll)[0]; if (claimAll && isActive(token)) { await click(claimAll, '모두 수령'); await sleep(CFG.delay); log('✅ 모두 수령 완료!'); STATE.rewardsDone = true; return; } // 개별 수령 let n = 0; for (const btn of $(SEL.claimSingle)) { if (!isActive(token)) break; const t = textOf(btn); if (t.includes('수령') || t.toLowerCase().includes('claim')) { await click(btn, `수령: ${t}`); await sleep(CFG.delay); n++; } } if (n > 0) { log(`✅ 개별 보상 ${n}개 수령.`); STATE.rewardsDone = true; } else { log('⚠️ 수령 버튼을 찾지 못했습니다. 직접 확인해 주세요.'); } }; // ───────────────────────────────────────────── // 공개 API // ───────────────────────────────────────────── const ZZZ = { /** 자동 플레이 시작 */ async start({ chapters = null, noRewards = false } = {}) { if (STATE.running) { log('이미 실행 중입니다.'); return; } STATE.running = true; const token = ++STATE.runToken; log('🚀 자동 플레이 시작!'); try { // 초기 화면 처리 await dismissOverlays(token); // 로그인 확인 if (!await waitLogin(token)) { log('로그인 필요. ZZZ.start() 로 다시 시작하세요.'); if (STATE.runToken === token) STATE.running = false; return; } await sleep(CFG.longDelay); await waitLoading(15000); await waitForProgressSurface(token, 30000); // 장소 선택 const targets = chapters ? CHAPTERS.filter(c => chapters.includes(c.id)) : CHAPTERS; for (const ch of targets) { if (!isActive(token)) break; if (!await enterChapter(ch, token)) continue; await collectEndings(ch, token); } // 보상 수령 if (!noRewards && isActive(token)) await claimRewards(token); } catch (err) { log(`❌ 오류 발생: ${err.message}`); console.error(err); } finally { if (STATE.runToken === token) { STATE.running = false; ZZZ.status(); } } }, /** 중지 */ stop() { STATE.running = false; STATE.runToken++; log('⏹ 중지 요청됨. 현재 단계 후 정지합니다.'); }, /** 현재 상태 출력 */ status() { log('─'.repeat(40)); log('📊 현재 상태:'); let total = 0; for (const ch of CHAPTERS) { const s = STATE.collected[ch.code]; total += s.size; log(` ${ch.name}: ${s.size}/${ch.target} ${s.size >= ch.target ? '✅' : '🔄'}`); for (const e of s) log(` • ${e}`); } log(` 보상 수령: ${STATE.rewardsDone ? '✅ 완료' : '미완료'}`); log(` 총 결말: ${total}/26`); log(` 총 런 횟수: ${STATE.totalRuns}`); log('─'.repeat(40)); return STATE; }, /** 특정 장소만 플레이 (예: ZZZ.chapter(1)) */ chapter(id) { return this.start({ chapters: [id] }); }, /** 보상만 수령 */ rewards() { const wasRunning = STATE.running; if (!STATE.running) { STATE.running = true; STATE.runToken++; } const token = STATE.runToken; return claimRewards(token).finally(() => { if (!wasRunning && STATE.runToken === token) STATE.running = false; }); }, /** 현재 화면에서 자동화 후보 확인 */ debug() { const rows = interactiveElements().slice(0, 80).map((el, i) => { const r = el.getBoundingClientRect(); return { i, tag: el.tagName, text: textOf(el).slice(0, 80), className: String(el.className || '').slice(0, 100), rect: `${Math.round(r.x)},${Math.round(r.y)} ${Math.round(r.width)}x${Math.round(r.height)}`, }; }); console.table(rows); log(`화면 텍스트 일부: ${bodyText().slice(0, 500).replace(/\n/g, ' / ')}`); return rows; }, /** 설정 변경 (예: ZZZ.config({ delay: 2000 })) */ config(opts) { Object.assign(CFG, opts); log('설정 업데이트:', CFG); }, /** 수집 현황 초기화 */ reset() { CHAPTERS.forEach(ch => { STATE.collected[ch.code] = new Set(); }); STATE.rewardsDone = false; STATE.totalRuns = 0; STATE.storyTreeTapIndex = 0; STATE.log = []; log('상태 초기화 완료.'); }, CFG, STATE, CHAPTERS, }; // 전역에 노출 window.ZZZ = ZZZ; // ───────────────────────────────────────────── // 설치 완료 메시지 // ───────────────────────────────────────────── console.log(` %c🎮 좋은 아침, 로스캘리퍼 자동 플레이어 로드 완료! %c 명령어: ZZZ.start() 전체 자동 플레이 (모든 장소 + 보상 수령) ZZZ.start({ chapters: [1,2] }) 특정 장소만 (1~5) ZZZ.chapter(3) 3번 장소(붐스콘)만 플레이 ZZZ.rewards() 보상만 수령 ZZZ.stop() 현재 단계 후 중지 ZZZ.status() 수집 현황 확인 ZZZ.debug() 현재 화면 자동화 후보 출력 ZZZ.config({ delay: 2000 }) 딜레이 조정(ms) ZZZ.reset() 수집 상태 초기화 `, 'color:#f0c040;font-size:14px;font-weight:bold;', 'color:#aed6f1;font-size:12px;' ); })();