Veri işleme katmanı
// ==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');
}
})();