/**
* ============================================================
* 좋은 아침, 로스캘리퍼 — 브라우저 콘솔 자동 플레이어
* ============================================================
* 사용법:
* 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<endingName> }
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;'
);
})();