Quest Runner — Mapper

Veri işleme katmanı

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         Quest Runner — Mapper
// @namespace    popmundo-qr
// @description  Veri işleme katmanı
// @version      0.1
// @author       luke-james-gibson
// @license      MIT
// @match        https://*.popmundo.com/*
// @grant        none
// ==/UserScript==

(function () {
    'use strict';

    function waitEngine(fn) {
        if (window.QR?.ready) { fn(); return; }
        setTimeout(() => waitEngine(fn), 150);
    }
    waitEngine(init);

    function init() {
        const QR = window.QR;
        const S  = QR.S;

        // DIR MAP
        const DIR = {
            N:'North', NE:'NorthEast', NW:'NorthWest',
            S:'South', SE:'SouthEast', SW:'SouthWest',
            E:'East',  W:'West', UP:'Up', DOWN:'Down',
            NORTH:'North', NORTHEAST:'NorthEast', NORTHWEST:'NorthWest',
            SOUTH:'South', SOUTHEAST:'SouthEast', SOUTHWEST:'SouthWest',
            EAST:'East',   WEST:'West', UPWARD:'Up', DOWNWARD:'Down',
            K:'North', KD:'NorthEast', KB:'NorthWest',
            G:'South', GD:'SouthEast', GB:'SouthWest',
            D:'East',  B:'West', Y:'Up', A:'Down',
            KUZEY:'North', KUZEYDOGU:'NorthEast', KUZEYBATI:'NorthWest',
            GUNEY:'South', GUNEYDOGU:'SouthEast', GUNEYBATI:'SouthWest',
            DOGU:'East',   BATI:'West', YUKARI:'Up', ASAGI:'Down',
            L:'East', O:'West', NO:'NorthWest', SO:'SouthWest', NL:'NorthEast', SL:'SouthEast',
            NORTE:'North', SUL:'South', LESTE:'East', OESTE:'West',
            NORDESTE:'NorthEast', NOROESTE:'NorthWest', SUDESTE:'SouthEast', SUDOESTE:'SouthWest',
        };

        const DIR_ARROWS = {
            North:'↑', NorthEast:'↗', East:'→', SouthEast:'↘',
            South:'↓', SouthWest:'↙', West:'←', NorthWest:'↖',
            Up:'⬆', Down:'⬇',
        };

        function cleanToken(t) {
            return t.toUpperCase()
                .replace(/Ğ/g,'G').replace(/Ş/g,'S').replace(/İ/g,'I').replace(/ı/g,'I')
                .replace(/Ü/g,'U').replace(/Ö/g,'O').replace(/Ç/g,'C')
                .replace(/[^A-Z]/g,'');
        }

        QR.dirArrow  = d => DIR_ARROWS[d] || d;
        QR.resolveDir = t => DIR[cleanToken(t)] || null;

        // PARSER
        const norm = s => s.trim().toLowerCase().replace(/\s+/g,' ');

        function parseUseItem(raw) {
            const body = raw.slice(9).trim();
            if (!body) return { item:null, location:null };
            const at = body.indexOf('@');
            if (at >= 0) {
                const rawItem = body.slice(0, at).trim();
                return { item:(!rawItem || rawItem==='?') ? null : norm(rawItem), location:norm(body.slice(at+1)) };
            }
            const rawItem = body.trim();
            return { item:(!rawItem || rawItem==='?') ? null : norm(rawItem), location:null };
        }

        function trySimpleLine(line, steps) {
            // "Use <Location>" → use_item ? @ <Location>
            if (/^use\s+\S/i.test(line) && !/^use_item/i.test(line)) {
                const loc = line.slice(4).trim();
                if (loc) { steps.push({ type:'use_item', item:null, location:norm(loc) }); return true; }
            }
            // "<N> minutes/min/dk"
            const minM = line.match(/^(\d+)\s*(?:minutes?|min|dk)$/i);
            if (minM) { steps.push({ type:'passage', minutes:parseInt(minM[1]) }); return true; }
            // "Arrival <Label>"
            if (/^arrival\s+/i.test(line)) {
                const label = line.slice(8).trim().replace(/\s+/g,'_').toUpperCase();
                steps.push({ type:'checkpoint', label:'ARRIVAL_'+label }); return true;
            }
            return false;
        }

        function parseDirToken(token, errors) {
            if (!token) return null;
            const bm = token.match(/^[\[\(](.+)[\]\)]$/);
            if (bm) {
                const dirs = bm[1].split('|').map(QR.resolveDir).filter(Boolean);
                if (dirs.length) return { type:'move', dirs };
                errors && errors.push('Bad branch: '+token); return null;
            }
            const dir = QR.resolveDir(token);
            if (dir) return { type:'move', dirs:[dir] };
            if (token.length>1 && !/^\d+$/.test(token)) errors && errors.push('Unknown dir: "'+token+'"');
            return null;
        }

        function extractMin(line) {
            const m = line.match(/(\d+)\s*(min|dk)/i);
            return m ? parseInt(m[1]) : null;
        }

        function parseText(text) {
            const steps=[], errors=[];
            for (const raw of text.split('\n')) {
                const line = raw.trim();
                if (!line || /^(#|\/)/.test(line)) continue;
                if (/^move\s/i.test(line)) {
                    line.slice(5).trim().split(/[\s,\-]+/).forEach(t => {
                        const s=parseDirToken(t,errors); if(s) steps.push(s);
                    });
                } else if (/^use_item/i.test(line)) {
                    steps.push({ type:'use_item', ...parseUseItem(line) });
                } else if (/^passage/i.test(line)) {
                    steps.push({ type:'passage', minutes:extractMin(line) });
                } else if (/^wait/i.test(line)) {
                    steps.push({ type:'wait', minutes:extractMin(line) });
                } else if (/^checkpoint/i.test(line)) {
                    steps.push({ type:'checkpoint', label:line.slice(10).trim()||null });
                } else if (trySimpleLine(line, steps)) {
                    // handled
                } else {
                    const tokens = line.split(/[\s,\-]+/).filter(Boolean);
                    let any=false;
                    tokens.forEach(t => { const s=parseDirToken(t,errors); if(s){steps.push(s);any=true;} });
                    if (!any && tokens.length) errors.push('Unknown: "'+line.slice(0,40)+'"');
                }
            }
            return { steps, errors };
        }
        QR.parseText = parseText;

        // QUEST STORE
        const QUEST_KEY = 'qr_quests';
        const QStore = {
            load:   ()         => S.get(QUEST_KEY,{}),
            save:   db         => S.set(QUEST_KEY,db),
            get:    id         => S.get(QUEST_KEY,{})[id]||null,
            delete: id         => { const db=S.get(QUEST_KEY,{}); delete db[id]; S.set(QUEST_KEY,db); },
            upsert: q          => {
                const db=S.get(QUEST_KEY,{});
                if (db[q.id]) q.versions=[...(db[q.id].versions||[]),{ts:Date.now(),text:db[q.id].text}];
                else q.versions=q.versions||[];
                db[q.id]=q; S.set(QUEST_KEY,db);
            },
            new: (name,text)   => { const {steps,errors}=parseText(text); return {id:'q_'+Date.now(),name,text,sequence:steps,versions:[],builtin:false,errors}; },
            copy: (srcId,name) => { const src=S.get(QUEST_KEY,{})[srcId]; return src?{...src,id:'q_'+Date.now(),name,versions:[],builtin:false}:null; },
            createVariant: (srcId,stepIdx,newItem) => {
                const src=S.get(QUEST_KEY,{})[srcId]; if(!src) return null;
                const varNum=(src.variants||0)+1;
                const seq=src.sequence.map((s,i)=>(i===stepIdx&&s.type==='use_item')?{...s,item:newItem}:s);
                const db=S.get(QUEST_KEY,{});
                if(db[srcId]) db[srcId].variants=varNum;
                const q={...src,id:'q_'+Date.now(),name:src.name+' v'+varNum,sequence:seq,versions:[],builtin:false,variantOf:srcId};
                db[q.id]=q; S.set(QUEST_KEY,db); return q;
            },
        };
        QR.quests = QStore;

        // MAPPER GRAPH
        const MAP_KEY = 'qr_mapdata';
        const Mapper = {
            load:  ()   => S.get(MAP_KEY,{rooms:{},edges:[]}),
            save:  data => S.set(MAP_KEY,data),
            clear: ()   => S.del(MAP_KEY),

            recordRoom: (name,dirs,items,detailed) => {
                if(!name) return;
                const data=Mapper.load(), ex=data.rooms[name]||{};
                data.rooms[name]={ name, dirs, items, visits:(ex.visits||0)+1, lastSeen:Date.now(), detailed:detailed||ex.detailed||false };
                Mapper.save(data);
            },
            recordEdge: (from,dir,to) => {
                if(!from||!dir||!to||from===to) return;
                const data=Mapper.load();
                const ex=data.edges.find(e=>e.from===from&&e.dir===dir);
                if(ex){ex.to=to;ex.ts=Date.now();}
                else data.edges.push({from,dir,to,ts:Date.now()});
                Mapper.save(data);
            },
            snapshot: () => {
                const name=QR.dom?.locName?.(); if(!name) return null;
                return { name, dirs:QR.dom.activeDirections(), items:QR.dom.getItems().map(i=>i.name) };
            },
            exportJSON:  () => JSON.stringify(Mapper.load(),null,2),
            exportDraft: () => {
                const data=Mapper.load(), edges=[...data.edges].sort((a,b)=>a.ts-b.ts);
                if(!edges.length) return '# Harita verisi yok';
                const lines=['# Quest taslağı — haritadan otomatik']; let prev=null;
                for(const e of edges) {
                    if(e.from!==prev) lines.push('\n# '+e.from);
                    lines.push('move '+e.dir);
                    prev=e.to;
                    const room=data.rooms[e.to];
                    room?.items?.forEach(item=>lines.push('# use_item '+item+' @ '+e.to));
                }
                return lines.join('\n');
            },
            buildMapHTML: () => {
                const data=Mapper.load(), rooms=Object.values(data.rooms);
                if(!rooms.length) return '<div style="opacity:.5;padding:10px;text-align:center;">Henüz harita verisi yok</div>';
                const listRows=rooms.sort((a,b)=>b.lastSeen-a.lastSeen).map(r=>{
                    const dirBtns=r.dirs.map(d=>{
                        const edge=data.edges.find(e=>e.from===r.name&&e.dir===d);
                        const lc=edge?'data-to="'+edge.to+'"':'';
                        return '<button class="qr-map-go" '+lc+' data-dir="'+d+'" '+
                            'style="background:#1565C0;color:white;border:none;border-radius:4px;padding:2px 5px;font-size:11px;cursor:pointer;margin:1px;">'+
                            QR.dirArrow(d)+' '+d+'</button>';
                    }).join('');
                    return '<tr style="border-bottom:1px solid rgba(255,255,255,.06);">'+
                        '<td style="padding:4px 6px;font-size:11px;">'+r.name+'</td>'+
                        '<td style="padding:4px 6px;">'+dirBtns+'</td>'+
                        '<td style="padding:4px 6px;font-size:10px;color:#FFD600;">'+(r.items.join(', ')||'—')+'</td>'+
                        '<td style="padding:4px 6px;font-size:9px;opacity:.4;">×'+r.visits+'</td></tr>';
                }).join('');
                const edgeLines=data.edges.slice(-30).map(e=>
                    '<div style="font-size:10px;padding:1px 0;">'+
                    '<span style="opacity:.5;">'+e.from+'</span> '+
                    '<span style="color:#FFD600;">'+QR.dirArrow(e.dir)+' '+e.dir+'</span> → '+e.to+'</div>'
                ).join('');
                return '<div style="max-height:180px;overflow-y:auto;margin-bottom:8px;">'+
                    '<table style="width:100%;border-collapse:collapse;">'+
                    '<thead><tr style="font-size:9px;opacity:.4;border-bottom:1px solid rgba(255,255,255,.15);">'+
                    '<th style="padding:3px;text-align:left;">Oda</th><th style="padding:3px;text-align:left;">Çıkışlar</th>'+
                    '<th style="padding:3px;text-align:left;">Item</th><th></th></tr></thead>'+
                    '<tbody>'+listRows+'</tbody></table></div>'+
                    '<div style="max-height:120px;overflow-y:auto;border-top:1px solid rgba(255,255,255,.1);padding-top:6px;">'+
                    '<div style="font-size:9px;opacity:.4;margin-bottom:3px;">Kenarlar (son 30)</div>'+edgeLines+'</div>';
            },
        };
        QR.mapper = Mapper;

        // NORTH POLE SEED
        (function(){
            const db=QStore.load(); if(db['north-pole']) return;
            const NP=[
                '# ICY CAVERNS - Giriş','checkpoint ICY_ENTRANCE',
                'move North SouthEast East NorthEast','passage 16min','',
                '# ICY CAVERNS - Yol','checkpoint ICY_PATH_START',
                'move East SouthEast South SouthEast SouthEast Down South SouthWest South SouthEast South South SouthEast South SouthWest South SouthEast SouthWest SouthEast East SouthEast SouthWest West',
                'use_item ? @ Icy Caverns','',
                '# ICY CAVERNS - Dönüş','checkpoint ICY_RETURN',
                'move East NorthEast NorthWest West NorthWest NorthEast NorthWest North NorthEast North NorthWest North North NorthWest North NorthEast North Up NorthWest NorthWest North NorthWest West',
                'passage 16min','',
                '# ICEPEAK MOUNTAIN - Giriş','checkpoint ICEPEAK_ENTRANCE',
                'move SouthWest West NorthWest West SouthWest West','passage 16min','',
                '# ICEPEAK MOUNTAIN - Yol','checkpoint ICEPEAK_PATH_START',
                'move NorthWest Up NorthWest Up NorthEast Up NorthEast Down SouthEast East Down NorthEast North Up NorthEast Up West Down NorthWest Up North Up Up',
                'use_item ? @ Icepeak Mountain','',
                '# ICEPEAK MOUNTAIN - Dönüş','checkpoint ICEPEAK_RETURN',
                'move Down Down South Down SouthEast Up East Down SouthWest Down South SouthWest Up West NorthWest Up SouthWest Down SouthWest Down SouthEast Down SouthEast',
                'passage 16min','',
                '# BLINDING BLIZZARD - Giriş','checkpoint BLIZZARD_ENTRANCE',
                'move East NorthEast East North NorthEast East NorthEast North','passage 16min','',
                '# BLINDING BLIZZARD - Yol','checkpoint BLIZZARD_PATH_START',
                'move North North East North East North East North East North East North East North East',
                'use_item ? @ Blinding Blizzard','move North West','passage 16min','',
                '# FIREPLACE','checkpoint FIREPLACE',
                'move South SouthWest West North','use_item ? @ Fireplace','',
                '# TREE','checkpoint TREE',
                'move South SouthWest NorthWest North','use_item ? @ Tree',
            ].join('\n');
            const {steps}=parseText(NP);
            db['north-pole']={id:'north-pole',name:'North Pole',text:NP,sequence:steps,versions:[],builtin:true};
            QStore.save(db);
        })();

        // MAPPER: sayfa yüklenince oda kaydet
        let _prevRoom=null, _prevDir=null;
        function mapperOnLoad() {
            if(!QR.page.isCompass()) return;
            const snap=Mapper.snapshot(); if(!snap) return;
            const char=QR.char.current();
            const mapping=char?S.get('qr_map_mode_'+char,false):false;
            Mapper.recordRoom(snap.name,snap.dirs,snap.items,mapping);
            if(_prevRoom&&_prevDir) Mapper.recordEdge(_prevRoom,_prevDir,snap.name);
            _prevRoom=snap.name; _prevDir=null;
            QR.emit('map_updated',snap,Mapper.load());
        }
        QR.on('moved',       (c,dir)=>{ _prevRoom=QR.dom?.locName?.(); _prevDir=dir; });
        QR.on('manual_move', (c,dir)=>{ _prevRoom=QR.dom?.locName?.(); _prevDir=dir; });
        mapperOnLoad();

        QR.mapperReady=true;
        QR.emit('mapper_ready');
        console.log('[QR] Mapper v0.1 ready');
    }
})();