RC-15-PLACE
release-tag / release-image (push) Successful in 3m56s

This commit is contained in:
2026-08-14 10:13:57 +02:00
parent 47c523dd98
commit 52d33b25fd
13 changed files with 1008 additions and 43 deletions
+77 -16
View File
@@ -325,32 +325,32 @@ function userShell(){
<aside class="signal-panel glass" id="signalPanel">
<div class="panel-title"><span>DEIN SIGNAL</span><span class="chip" id="guessCountdown">—</span></div>
<div class="rank-hero"><small>RANK</small><strong id="rank">#—</strong></div>
<div class="signal-metrics"><div><span>Score</span><b id="score">0.00</b></div><div><span>Wins</span><b id="wins">0</b></div><div><span>Clients</span><b id="clientmetric">0</b></div></div>
<div class="signal-metrics"><div><span>Score</span><b id="score">0.00</b></div><div><span>Wins</span><b id="wins">0</b></div><div><span>Place PX</span><b id="placeBadge">0</b></div><div><span>Clients</span><b id="clientmetric">0</b></div></div>
<div id="beaconChoice" class="beacon-choice hidden"><div class="leaderboard-head"><span>BEACON PATH</span><small id="beaconMeta">externer Zufallsimpuls</small></div><div class="beacon-buttons"><button data-beacon-path="PULSE">PULSE</button><button data-beacon-path="FLUX">FLUX</button><button data-beacon-path="ORBIT">ORBIT</button></div><small id="beaconLast">Wähle vor dem nächsten Los einen Pfad.</small></div>
<div class="proximity-mini"><div class="leaderboard-head"><span>TARGET RADAR</span><small>100 = Task</small></div><div id="proximityRows"></div></div>
<div class="identity-block"><span class="eyebrow">IDENTITÄT</span><code id="cid">…</code><div class="identity-actions"><button id="exportid">SICHERN</button><label class="button">IMPORT<input id="importid" hidden type="file" accept="application/json"></label><button id="mynfts">MEINE NFTS</button><button id="hostedCode">HOSTED CODE</button></div><div id="myNftsPanel" class="identity-nfts hidden"></div><div class="chips" id="unlocks"></div></div>
<div class="identity-block"><span class="eyebrow">IDENTITÄT</span><code id="cid">…</code><div class="identity-actions"><button id="exportid">SICHERN</button><label class="button">IMPORT<input id="importid" hidden type="file" accept="application/json"></label><button id="mynfts">MEINE NFTS</button><a class="button" href="/place">PLACE</a><button id="hostedCode">HOSTED CODE</button></div><div id="myNftsPanel" class="identity-nfts hidden"></div><div class="chips" id="unlocks"></div></div>
<div class="leaderboard-head"><span>LEADERBOARD</span><a href="/leaderboard">ECHTZEIT →</a></div><div class="leaderboard" id="leaders"></div>
</aside>
<div class="distance-legend glass"><b>TARGET FIELD</b><span>0 · WEIT</span><i></i><span>75</span><span>90</span><span>95</span><span>99+</span><span>100 · TASK</span></div>
<div class="node-limit glass"><span>MAX NODES</span><input id="maxnodes" type="range" min="100" max="25000" step="100" value="2000"><b id="maxnodesvalue">2000</b></div>
<nav class="dock glass" aria-label="3D-Steuerung">
<button id="chooseTask">TASKS</button><button id="toggleMobile">MOBILE</button><button id="toggleDetails">DETAILS</button><button id="toggleProximity" class="active">TARGET FIELD</button><button id="toggleRotate" class="active">ORBIT</button><button id="toggleLabels" class="active">LABELS</button><button id="toggleEdges" class="active">SIGNALWEGE</button><button id="toggleShells" class="active">SCORE-RINGE</button><button id="toggleLOD" class="active">LOD</button><button id="toggleEco">ECO</button><button id="resetView">ZENTRIEREN</button><a class="dock-link" href="/leaderboard">RANKING</a>
<button id="chooseTask">TASKS</button><button id="toggleMobile">MOBILE</button><button id="toggleDetails">DETAILS</button><button id="toggleProximity" class="active">TARGET FIELD</button><button id="toggleRotate" class="active">ORBIT</button><button id="toggleLabels" class="active">LABELS</button><button id="toggleEdges" class="active">SIGNALWEGE</button><button id="toggleShells" class="active">SCORE-RINGE</button><button id="toggleLOD" class="active">LOD</button><button id="toggleEco">ECO</button><button id="resetView">ZENTRIEREN</button><a class="dock-link" href="/place">PLACE</a><a class="dock-link" href="/leaderboard">RANKING</a>
</nav>
<section id="taskLanding" class="task-landing visible">
<div class="task-landing-bg"></div>
<div class="task-landing-inner">
<div class="task-landing-head">
<div><span class="eyebrow">CHOOSE YOUR FIELD</span><h1>Wähle deinen Task</h1><p>Jeder Task ist ein eigener Wahrscheinlichkeitsraum. Du kannst jederzeit wechseln; deine Identität und bereits erreichte Bestwerte bleiben erhalten.</p></div>
<div class="task-landing-id glass"><span>DEINE IDENTITÄT</span><code id="landingCid">initialisiere …</code><div class="landing-identity-actions"><button id="landingExportId">SICHERN</button><label class="button">IMPORT<input id="landingImportId" hidden type="file" accept="application/json"></label><button id="landingMyNfts">MEINE NFTS</button><button id="landingHostedCode">HOSTED CODE</button></div><div id="landingNftsPanel" class="landing-owned-nfts hidden"></div><button id="landingRefresh">AKTUALISIEREN</button></div>
<div class="task-landing-id glass"><span>DEINE IDENTITÄT</span><code id="landingCid">initialisiere …</code><div class="landing-identity-actions"><button id="landingExportId">SICHERN</button><label class="button">IMPORT<input id="landingImportId" hidden type="file" accept="application/json"></label><button id="landingMyNfts">MEINE NFTS</button><a class="button" href="/place">PLACE</a><button id="landingHostedCode">HOSTED CODE</button></div><div id="landingNftsPanel" class="landing-owned-nfts hidden"></div><button id="landingRefresh">AKTUALISIEREN</button></div>
</div>
<div id="taskCards" class="task-cards"><div class="task-card-loading">Tasks werden geladen …</div></div>
<div class="task-landing-foot"><span>Ein Client kann immer nur mit <b>einem</b> Task aktiv verbunden sein.</span><a href="/leaderboard">Echtzeit-Leaderboard →</a></div>
<div class="task-landing-foot"><span>Ein Client kann immer nur mit <b>einem</b> Task aktiv verbunden sein.</span><span><a href="/place">Neural Place →</a> · <a href="/leaderboard">Echtzeit-Leaderboard →</a></span></div>
</div>
</section>`;
}
async function runUser(){
userShell(); let task=null,points=[],cid='',scheduler=null,countdownTimer=null,ws=null,submitting=false,nextGuessAt=0,refreshing=false,landingBusy=false,landingTimer=null,wsReconnectTimer=null,wsBackoff=500,beaconPath=localStorage.getItem('neuralhunt.beaconPath')||'PULSE';
userShell(); let task=null,points=[],cid='',scheduler=null,countdownTimer=null,ws=null,submitting=false,nextGuessAt=0,refreshing=false,landingBusy=false,landingTimer=null,wsReconnectTimer=null,wsBackoff=500,beaconPath=localStorage.getItem('neuralhunt.beaconPath')||'PULSE',lastKnownScore=-1,lastPlaceRefresh=0;
const map=new NeuralMap($('map'),{panelOffset:-115,onStats:s=>{if($('rendercount'))$('rendercount').textContent=s.render.toLocaleString('de-DE');if($('fpscount'))$('fpscount').textContent=s.fps}});
let detailsOpen=false;
const syncMobile=()=>{const on=document.documentElement.classList.contains('mobile-mode');$('toggleMobile').textContent=mobileButtonLabel();setActive('toggleMobile',on);$('signalPanel').classList.toggle('expanded',on&&detailsOpen);if(on){map.eco=true;map.labels=false;map.edges=false;map.zoom=Math.min(map.zoom,.96);setActive('toggleEco',true);setActive('toggleLabels',false);setActive('toggleEdges',false);if(+$('maxnodes').value>1000){$('maxnodes').value=1000;$('maxnodesvalue').textContent='1.000';map.update(points,cid,1000)}}else{map.eco=false;setActive('toggleEco',false)}map.resize()};
@@ -359,7 +359,7 @@ async function runUser(){
const renderRadar=()=>{const rows=proximityRows(points,cid);$('proximityRows').innerHTML=rows.map((p,i)=>`<div class="proximity-row ${p.client_id===cid?'self':''}"><span>${p.client_id===cid?'DU':`#${p.rank||i+1}`}</span><div><i style="width:${clamp(Number(p.score||0),0,100)}%"></i></div><b>${fmtScore(p.score)}</b></div>`).join('')||'<div class="empty small">Noch keine Signale</div>'};
const render=()=>{points=Array.isArray(points)?points:[];const budget=Math.min(10000,Math.max(300,(+$('maxnodes').value||task?.default_max_nodes||2000)*3));if(points.length>budget){const own=points.find(p=>p.client_id===cid),top=points.filter(p=>p.client_id!==cid).sort((a,b)=>Number(b.score||0)-Number(a.score||0)).slice(0,budget-(own?1:0));points=own?[...top,own]:top}if($('nodecount'))$('nodecount').textContent=points.length.toLocaleString('de-DE');if($('clientmetric'))$('clientmetric').textContent=points.length.toLocaleString('de-DE');map.update(points,cid,+$('maxnodes').value);renderRadar()};
const countdown=()=>{let text='—';if(task?.paused)text='PAUSE';else if(task&&nextGuessAt){const sec=Math.max(0,Math.ceil((nextGuessAt-Date.now())/1000));text=sec?`${sec}s`:'jetzt'}$('guessCountdown').textContent=text;if($('guessMobile'))$('guessMobile').textContent=text};
async function refreshMe(){try{const me=await api('/api/me'),rank='#'+(me.rank||'—'),score=fmtScore(me.score);$('rank').textContent=rank;$('score').textContent=score;if($('rankMobile'))$('rankMobile').textContent=rank;if($('scoreMobile'))$('scoreMobile').textContent=score;$('wins').textContent=me.wins||0;$('unlocks').innerHTML=(me.unlocks||[]).map(x=>`<span>${esc(x)}</span>`).join('')}catch{}}
async function refreshMe(){try{const me=await api('/api/me'),rawScore=Number(me.score||0),rank='#'+(me.rank||'—'),score=fmtScore(rawScore);$('rank').textContent=rank;$('score').textContent=score;if($('rankMobile'))$('rankMobile').textContent=rank;if($('scoreMobile'))$('scoreMobile').textContent=score;$('wins').textContent=me.wins||0;$('unlocks').innerHTML=(me.unlocks||[]).map(x=>`<span>${esc(x)}</span>`).join('');const shouldRefreshPlace=lastKnownScore<0||rawScore>lastKnownScore+1e-9||Date.now()-lastPlaceRefresh>60000;if(shouldRefreshPlace){try{const pl=await api('/api/place/me');if($('placeBadge'))$('placeBadge').textContent=Number(pl?.wallet?.available_pixels||0).toLocaleString('de-DE');lastPlaceRefresh=Date.now()}catch{}}lastKnownScore=rawScore}catch{}}
async function refreshLeaders(){try{const raw=await api('/api/leaderboard'),ls=Array.isArray(raw)?raw:[];$('leaders').innerHTML=ls.slice(0,12).map((l,i)=>`<div class="leader-row ${l.client_id===cid?'self':''}"><span class="leader-rank">${i+1}</span><span><b>${esc(shortID(l.client_id,11))}</b><small>${l.wins} Wins · live ${fmtScore(l.live_score)}</small></span><strong>${Number(l.best_score||0).toFixed(1)}</strong></div>`).join('')||'<div class="empty">Noch keine Teilnehmer</div>'}catch{}}
async function ensureSession(){if(!getToken()){const x=await loginIdentity();setToken(x.token);return}try{const me=await api('/api/me');if(me?.client_id!==cid)throw Object.assign(new Error('identity mismatch'),{status:401})}catch(e){if(e.status!==401)throw e;clearToken();const x=await loginIdentity();setToken(x.token)}}
function stopTimers(){if(scheduler){clearInterval(scheduler);scheduler=null}if(countdownTimer){clearInterval(countdownTimer);countdownTimer=null}nextGuessAt=0;countdown()}
@@ -412,7 +412,7 @@ async function runUser(){
function leaderboardShell(){
app.className='leaderboard-page';app.innerHTML=`
<header class="leaderboard-top glass"><div class="brand">${markHTML()}<div><strong>NEURAL HUNT / REALTIME LEADERBOARD</strong><small>Live score · wins · watermarked winner NFTs</small></div></div><div class="leaderboard-nav"><button id="lbMobile">MOBILE</button><a href="/">CLIENT</a></div></header>
<header class="leaderboard-top glass"><div class="brand">${markHTML()}<div><strong>NEURAL HUNT / REALTIME LEADERBOARD</strong><small>Live score · wins · watermarked winner NFTs</small></div></div><div class="leaderboard-nav"><button id="lbMobile">MOBILE</button><a href="/place">PLACE</a><a href="/">CLIENT</a></div></header>
<main class="lb-main">
<section class="lb-hero"><div class="lb-title"><span class="eyebrow">PUBLIC SIGNAL</span><h1>Echtzeit-Ranking</h1><p>Live-Score zeigt die beste Position in einem aktuell aktiven Task. Gewinner-Artefakte werden öffentlich ausschließlich über eine serverseitig erzeugte Vorschau mit Wasserzeichen angezeigt.</p></div><div class="lb-controls glass"><div class="segmented"><button id="lbLive" class="active">LIVE</button><button id="lbAll">ALL-TIME</button></div><input id="lbSearch" placeholder="Client-ID / Task suchen"><span id="lbState">verbinde …</span></div></section>
<section id="lbPodium" class="lb-podium"></section>
@@ -446,6 +446,67 @@ async function runLeaderboard(){
await refresh();const proto=location.protocol==='https:'?'wss':'ws';ws=new WebSocket(`${proto}://${location.host}/api/leaderboard/ws`);ws.onopen=()=>{$('lbState').textContent='LIVE · verbunden'};ws.onmessage=debounce;ws.onclose=()=>{$('lbState').textContent='WebSocket getrennt'};addEventListener('beforeunload',()=>{clearTimeout(refreshTimer);if(ws)ws.close()},{once:true});
}
function placeShell(){
app.className='place-page';app.innerHTML=`
<header class="place-top glass"><div class="brand">${markHTML()}<div><strong>NEURAL HUNT / PLACE</strong><small>Fortschritt wird zu Farbe · globale Live-Leinwand</small></div></div><div class="place-nav"><span id="placeLive" class="place-live"><i></i> verbinde …</span><a href="/leaderboard">RANKING</a><a href="/">HUNT</a></div></header>
<main class="place-layout">
<aside class="place-side place-left glass">
<div class="place-kicker">DEIN PLACE WALLET</div>
<div class="place-balance"><small>PUNKTE</small><strong id="placePoints">0</strong><span><b id="placePixelsAvailable">0</b> Pixel verfügbar</span></div>
<div class="place-wallet-grid"><span><small>ERHALTEN</small><b id="placeEarned">0</b></span><span><small>AUSGEGEBEN</small><b id="placeSpent">0</b></span><span><small>PLACEMENTS</small><b id="placePlacements">0</b></span><span><small>AKTUELL DEINE</small><b id="placeOwned">0</b></span></div>
<div class="place-economy"><span class="eyebrow">ÖKONOMIE</span><p id="placeEconomy">Hunt-Fortschritt erzeugt Place-Punkte.</p><div><b id="placeRate">—</b><small>Punkte pro +1,00 %</small></div><div><b id="placeCost">—</b><small>Punkte pro Pixel</small></div></div>
<div class="place-workers"><div class="place-section-head"><span>VERKNÜPFTE WORKER</span><small id="placeWorkerCount">0</small></div><div id="placeWorkerList" class="place-worker-list"><span class="empty small">keine</span></div></div>
<div class="place-earnings"><div class="place-section-head"><span>LETZTE FORTSCHRITTE</span><small>HUNT → PLACE</small></div><div id="placeEarnings" class="place-earning-list"></div></div>
</aside>
<section class="place-stage glass">
<div class="place-canvas-toolbar"><div><span class="eyebrow">NEURAL PLACE</span><b id="placeCoords">Pixel wählen</b></div><div class="place-canvas-stats"><span><b id="placeFilled">0</b> belegt</span><span><b id="placeTotal">0</b> Placements</span><span><b id="placeParticipants">0</b> Artists</span></div><div class="place-tools"><button id="placeZoomOut">−</button><button id="placeFit">FIT</button><button id="placeZoomIn">+</button></div></div>
<div id="placeViewport" class="place-viewport"><canvas id="placeCanvas"></canvas><div id="placeHover" class="place-hover hidden"></div><div class="place-hint">Scroll = Zoom · Ziehen = Pan · Klick = Pixel wählen</div></div>
<div class="place-compose"><div><span class="eyebrow">FARBE</span><div id="placePalette" class="place-palette"></div></div><div class="place-selection"><small>AUSWAHL</small><b id="placeSelection">—</b><span id="placeSelectionOwner">Wähle einen Pixel auf der Leinwand.</span></div><button id="placeSubmit" disabled>PIXEL SETZEN <span id="placeSubmitCost"></span></button></div>
</section>
<aside class="place-side place-right glass"><div class="place-section-head"><span>LIVE FEED</span><small id="placeRevision">REV 0</small></div><div id="placeFeed" class="place-feed"></div><div class="place-rules"><span class="eyebrow">REGEL</span><p>Kein Zeit-Cooldown. Die knappe Ressource ist echter Hunt-Fortschritt. Eigene Fortschritte und Fortschritte deiner verknüpften Worker landen im selben Wallet.</p></div></aside>
</main>
<div id="placeToast" class="place-toast hidden"></div>`;
}
async function runPlace(){
placeShell();
let cid='',snapshot=null,config=null,wallet=null,width=0,height=0,palette=[],revision=0,selectedColor=Number(localStorage.getItem('neuralhunt.place.color')||15),selected=null;
let pixels=null,meta=new Map(),filledCount=0,seenFeed=new Set(),off=document.createElement('canvas'),offctx=off.getContext('2d'),canvas=$('placeCanvas'),ctx=canvas.getContext('2d'),scale=1,panX=0,panY=0,dpr=1,drag=null,ws=null,pollTimer=null,walletTimer=null,toastTimer=null;
const pxKey=(x,y)=>y*width+x;
const fmtPoints=v=>Number(v||0).toLocaleString('de-DE',{maximumFractionDigits:3});
const toast=(text,bad=false)=>{const el=$('placeToast');el.textContent=text;el.classList.toggle('bad',!!bad);el.classList.remove('hidden');clearTimeout(toastTimer);toastTimer=setTimeout(()=>el.classList.add('hidden'),2600)};
async function ensurePlaceSession(){const id=await ensureIdentity();cid=await clientId(id.publicJwk);if(!getToken()){const x=await loginIdentity();setToken(x.token);return}try{const x=await api('/api/place/me');if(x?.wallet?.client_id!==cid)throw Object.assign(new Error('identity mismatch'),{status:401})}catch(e){if(e.status!==401)throw e;clearToken();const x=await loginIdentity();setToken(x.token)}}
function renderWallet(){if(!wallet||!config)return;$('placePoints').textContent=fmtPoints(wallet.balance_points);$('placePixelsAvailable').textContent=Number(wallet.available_pixels||0).toLocaleString('de-DE');$('placeEarned').textContent=fmtPoints(wallet.earned_points);$('placeSpent').textContent=fmtPoints(wallet.spent_points);$('placePlacements').textContent=Number(wallet.placements||0).toLocaleString('de-DE');$('placeOwned').textContent=Number(wallet.owned_pixels||0).toLocaleString('de-DE');$('placeRate').textContent=fmtPoints(config.points_per_score);$('placeCost').textContent=fmtPoints(config.pixel_cost);$('placeSubmitCost').textContent=`· ${fmtPoints(config.pixel_cost)} P`;$('placeEconomy').textContent=`+1,00 % Hunt-Fortschritt = ${fmtPoints(config.points_per_score)} Punkte. Ein Pixel kostet ${fmtPoints(config.pixel_cost)} Punkte.`;const workers=wallet.linked_workers||[];$('placeWorkerCount').textContent=workers.length;$('placeWorkerList').innerHTML=workers.length?workers.map(x=>`<code title="${esc(x)}">${esc(shortID(x,18))}</code>`).join(''):'<span class="empty small">Keine Worker verknüpft</span>';$('placeEarnings').innerHTML=(wallet.recent_earnings||[]).map(e=>{const worker=e.source_client_id!==cid;return `<div class="place-earning"><span class="place-source ${worker?'worker':''}">${worker?'WORKER':'DU'}</span><div><b>+${fmtPoints(e.points)} P</b><small>${Number(e.old_score||0).toFixed(2)} → ${Number(e.new_score||0).toFixed(2)} % · ${esc(shortID(e.task_id?.slice(-10),10))}</small></div></div>`}).join('')||'<span class="empty small">Noch kein vergüteter Fortschritt.</span>';updateSubmit()}
async function refreshWallet(){try{const x=await api('/api/place/me');config=x.config||config;wallet=x.wallet||wallet;renderWallet()}catch(e){if(e.status===401)return;}}
function renderPalette(){$('placePalette').innerHTML=palette.map((c,i)=>`<button class="place-swatch ${i===selectedColor?'active':''}" data-place-color="${i}" style="--swatch:${c}" aria-label="Farbe ${i+1}"><i></i></button>`).join('');document.querySelectorAll('[data-place-color]').forEach(b=>b.onclick=()=>{selectedColor=Number(b.dataset.placeColor);localStorage.setItem('neuralhunt.place.color',String(selectedColor));renderPalette();draw();updateSubmit()})}
function initBoard(list){pixels=new Uint8Array(width*height);pixels.fill(255);meta=new Map();filledCount=0;off.width=width;off.height=height;offctx.imageSmoothingEnabled=false;offctx.fillStyle='#f5f5f5';offctx.fillRect(0,0,width,height);for(const p of (list||[]))applyPixel(p,false);fit();draw()}
function applyPixel(p,redraw=true){if(!p||p.x<0||p.y<0||p.x>=width||p.y>=height)return;const k=pxKey(p.x,p.y),ci=Number(p.color_index);if(pixels[k]===255)filledCount++;pixels[k]=ci;meta.set(k,p);offctx.fillStyle=palette[ci]||'#fff';offctx.fillRect(p.x,p.y,1,1);revision=Math.max(revision,Number(p.revision||0));if($('placeRevision'))$('placeRevision').textContent=`REV ${revision.toLocaleString('de-DE')}`;if($('placeFilled'))$('placeFilled').textContent=filledCount.toLocaleString('de-DE');if($('placeTotal'))$('placeTotal').textContent=revision.toLocaleString('de-DE');if(selected&&selected.x===p.x&&selected.y===p.y)updateSelection();if(redraw)draw()}
function viewportRect(){return $('placeViewport').getBoundingClientRect()}
function resize(){const r=viewportRect();dpr=Math.min(2,window.devicePixelRatio||1);const w=Math.max(1,Math.floor(r.width*dpr)),h=Math.max(1,Math.floor(r.height*dpr));if(canvas.width!==w||canvas.height!==h){canvas.width=w;canvas.height=h;canvas.style.width=r.width+'px';canvas.style.height=r.height+'px'}draw()}
function fit(){const r=viewportRect();if(!width||!height||!r.width)return;scale=Math.max(.15,Math.min(r.width/width,r.height/height)*.88);panX=(r.width-width*scale)/2;panY=(r.height-height*scale)/2;draw()}
function boardPos(clientX,clientY){const r=viewportRect(),sx=clientX-r.left,sy=clientY-r.top;return {x:Math.floor((sx-panX)/scale),y:Math.floor((sy-panY)/scale),sx,sy}}
function draw(){if(!pixels)return;const r=viewportRect();ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,r.width,r.height);ctx.save();ctx.translate(panX,panY);ctx.scale(scale,scale);ctx.imageSmoothingEnabled=false;ctx.drawImage(off,0,0);if(scale>=9){ctx.strokeStyle='rgba(255,255,255,.13)';ctx.lineWidth=1/scale;ctx.beginPath();for(let x=0;x<=width;x++){ctx.moveTo(x,0);ctx.lineTo(x,height)}for(let y=0;y<=height;y++){ctx.moveTo(0,y);ctx.lineTo(width,y)}ctx.stroke()}if(selected){ctx.strokeStyle=palette[selectedColor]||'#fff';ctx.lineWidth=Math.max(2/scale,.12);ctx.strokeRect(selected.x+.06,selected.y+.06,.88,.88);ctx.strokeStyle='rgba(255,255,255,.95)';ctx.lineWidth=Math.max(1/scale,.07);ctx.strokeRect(selected.x+.18,selected.y+.18,.64,.64)}ctx.restore()}
function updateSelection(){if(!selected){$('placeSelection').textContent='—';$('placeSelectionOwner').textContent='Wähle einen Pixel auf der Leinwand.';$('placeCoords').textContent='Pixel wählen';updateSubmit();return}const p=meta.get(pxKey(selected.x,selected.y));$('placeSelection').textContent=`X ${selected.x} · Y ${selected.y}`;$('placeCoords').textContent=`${selected.x}, ${selected.y}`;$('placeSelectionOwner').textContent=p?`Aktuell von ${shortID(p.client_id,18)} · Rev ${Number(p.revision||0).toLocaleString('de-DE')}`:'Noch unbelegt';updateSubmit()}
function updateSubmit(){const can=!!selected&&config?.enabled&&Number(wallet?.balance_points||0)>=Number(config.pixel_cost||Infinity);$('placeSubmit').disabled=!can;if(config&&!config.enabled)$('placeSubmit').textContent='PLACE DEAKTIVIERT';else $('placeSubmit').innerHTML=`PIXEL SETZEN <span id="placeSubmitCost">· ${fmtPoints(config?.pixel_cost||0)} P</span>`}
function addFeed(p,prepend=true){const rev=Number(p?.revision||0);if(rev&&seenFeed.has(rev))return;if(rev)seenFeed.add(rev);const host=$('placeFeed'),row=document.createElement('div');row.className='place-feed-row';row.innerHTML=`<i style="--feed-color:${palette[Number(p.color_index)]||'#fff'}"></i><div><b>${esc(shortID(p.client_id,16))}</b><small>${Number(p.x)}, ${Number(p.y)} · Rev ${Number(p.revision||0).toLocaleString('de-DE')}</small></div>`;if(prepend)host.prepend(row);else host.append(row);while(host.children.length>40)host.lastElementChild.remove()}
async function syncChanges(){try{const x=await api(`/api/public/place/changes?after=${revision}`);for(const p of (x.pixels||[])){applyPixel(p,false);addFeed(p)}revision=Math.max(revision,Number(x.revision||0));$('placeRevision').textContent=`REV ${revision.toLocaleString('de-DE')}`;draw()}catch{}}
function openPlaceWS(){const proto=location.protocol==='https:'?'wss':'ws';ws=new WebSocket(`${proto}://${location.host}/api/place/ws`,['neuralhunt.v1']);ws.onopen=()=>{$('placeLive').innerHTML='<i></i> LIVE';$('placeLive').classList.add('on')};ws.onmessage=ev=>{try{const e=JSON.parse(ev.data);if(e.type==='place_pixel'){applyPixel(e.data);addFeed(e.data);if(e.data.client_id===cid)refreshWallet()}else if(e.type==='place_ready'&&Number(e.data?.revision||0)>revision)syncChanges()}catch{}};ws.onclose=()=>{$('placeLive').innerHTML='<i></i> RECONNECT';$('placeLive').classList.remove('on');setTimeout(()=>{if(document.body.contains(canvas))openPlaceWS()},1200)}}
try{
await ensurePlaceSession();
const [pub,me]=await Promise.all([api('/api/public/place'),api('/api/place/me')]);snapshot=pub;config=me.config||pub.config;wallet=me.wallet;width=Number(config.width);height=Number(config.height);palette=config.palette||[];revision=Number(pub.stats?.revision||0);selectedColor=clamp(selectedColor,0,Math.max(0,palette.length-1));renderWallet();renderPalette();initBoard(pub.pixels||[]);$('placeParticipants').textContent=Number(pub.stats?.participants||0).toLocaleString('de-DE');$('placeRevision').textContent=`REV ${revision.toLocaleString('de-DE')}`;for(const p of (pub.recent||[]).slice().reverse())addFeed(p);if(!config.enabled)toast('Neural Place ist aktuell nur lesbar.',true);
}catch(e){$('placeLive').textContent='STARTFEHLER';toast(e.message||'Place konnte nicht geladen werden',true);return}
const viewport=$('placeViewport');
viewport.onpointerdown=e=>{if(e.button!==0)return;viewport.setPointerCapture(e.pointerId);drag={id:e.pointerId,x:e.clientX,y:e.clientY,px:panX,py:panY,moved:false};canvas.style.cursor='grabbing'};
viewport.onpointermove=e=>{const b=boardPos(e.clientX,e.clientY);if(drag&&drag.id===e.pointerId){const dx=e.clientX-drag.x,dy=e.clientY-drag.y;if(Math.hypot(dx,dy)>4)drag.moved=true;panX=drag.px+dx;panY=drag.py+dy;draw()}if(b.x>=0&&b.y>=0&&b.x<width&&b.y<height){const p=meta.get(pxKey(b.x,b.y)),h=$('placeHover');h.innerHTML=`<b>${b.x}, ${b.y}</b><small>${p?esc(shortID(p.client_id,16)):'frei'}</small>`;h.style.left=clamp(b.sx+14,8,viewport.clientWidth-150)+'px';h.style.top=clamp(b.sy+14,8,viewport.clientHeight-55)+'px';h.classList.remove('hidden')}else $('placeHover').classList.add('hidden')};
viewport.onpointerup=e=>{if(!drag||drag.id!==e.pointerId)return;const wasMoved=drag.moved;drag=null;canvas.style.cursor='crosshair';if(!wasMoved){const b=boardPos(e.clientX,e.clientY);if(b.x>=0&&b.y>=0&&b.x<width&&b.y<height){selected={x:b.x,y:b.y};updateSelection();draw()}}};
viewport.onpointercancel=()=>{drag=null;canvas.style.cursor='crosshair'};
viewport.onwheel=e=>{e.preventDefault();const r=viewportRect(),mx=e.clientX-r.left,my=e.clientY-r.top,bx=(mx-panX)/scale,by=(my-panY)/scale,f=e.deltaY<0?1.18:.84,newScale=clamp(scale*f,.08,48);panX=mx-bx*newScale;panY=my-by*newScale;scale=newScale;draw()};
$('placeZoomIn').onclick=()=>{const r=viewportRect(),mx=r.width/2,my=r.height/2,bx=(mx-panX)/scale,by=(my-panY)/scale;scale=clamp(scale*1.35,.08,48);panX=mx-bx*scale;panY=my-by*scale;draw()};$('placeZoomOut').onclick=()=>{const r=viewportRect(),mx=r.width/2,my=r.height/2,bx=(mx-panX)/scale,by=(my-panY)/scale;scale=clamp(scale/1.35,.08,48);panX=mx-bx*scale;panY=my-by*scale;draw()};$('placeFit').onclick=fit;
$('placeSubmit').onclick=async()=>{if(!selected)return;const b=$('placeSubmit');b.disabled=true;try{const out=await api('/api/place/pixel',{method:'POST',body:JSON.stringify({x:selected.x,y:selected.y,color_index:selectedColor})});wallet=out.wallet||wallet;applyPixel(out.pixel);addFeed(out.pixel);renderWallet();if(out.wallet_refresh_required)refreshWallet();toast(`Pixel ${selected.x}, ${selected.y} gesetzt`)}catch(e){toast(e.code==='insufficient_place_points'?'Nicht genug Place-Punkte. Verdiene Fortschritt im Hunt.':e.message||'Placement fehlgeschlagen',true)}finally{updateSubmit()}};
const onResize=()=>resize();addEventListener('resize',onResize);resize();openPlaceWS();pollTimer=setInterval(syncChanges,5000);walletTimer=setInterval(refreshWallet,5000);addEventListener('beforeunload',()=>{removeEventListener('resize',onResize);clearInterval(pollTimer);clearInterval(walletTimer);if(ws){ws.onclose=null;ws.close()}},{once:true});
}
function adminLoginShell(){app.className='login';app.innerHTML=`<div class="login-card glass"><div class="brand">${markHTML()}<div><strong>NEURAL HUNT / ADMIN</strong><small>Control plane</small></div></div><input id="adminuser" value="admin" placeholder="Benutzer"><input id="adminpass" type="password" placeholder="Passwort"><button id="adminlogin">ANMELDEN</button><p class="danger statusline" id="adminerr"></p></div>`}
function adminShell(){
app.className='admin';app.innerHTML=`
@@ -465,8 +526,8 @@ async function runAdmin(){
const setAdminPanel=name=>{const grid=$('adminGrid');grid.classList.remove('mobile-show-map','mobile-show-tasks','mobile-show-control');grid.classList.add(`mobile-show-${name}`);document.querySelectorAll('[data-admin-panel]').forEach(b=>b.classList.toggle('active',b.dataset.adminPanel===name));if(name==='map')setTimeout(()=>map.resize(),30)};
const syncAdminMobile=()=>{const on=document.documentElement.classList.contains('mobile-mode');$('adminMobileToggle').textContent=mobileButtonLabel();setActive('adminMobileToggle',on);if(on){map.eco=true;map.labels=false;map.edges=false;map.zoom=Math.min(map.zoom,.94);setActive('adminEco',true);setActive('adminEdges',false);if(+$('adminmaxnodes').value>1500){$('adminmaxnodes').value=1500;$('adminmaxvalue').textContent='1.500'}}else{map.eco=false;setActive('adminEco',false)}map.resize()};
$('adminMobileToggle').onclick=()=>toggleMobileMode();document.querySelectorAll('[data-admin-panel]').forEach(b=>b.onclick=()=>setAdminPanel(b.dataset.adminPanel));window.addEventListener('neuralhunt-mobile-mode',syncAdminMobile);syncAdminMobile();
const runtimeKeys=['guess_min_interval_sec','client_submit_interval_sec','guess_lottery_window_sec','guess_lottery_max_accepted','beacon_hunt_enabled','beacon_bonus_weight','sybil_pow_bits','sybil_warmup_sec','openai_max_calls_1h','openai_max_calls_24h','openai_max_cost_24h_usd','openai_budget_reserve_usd','task_range_bits','active_task_count','presence_ttl_sec','default_max_nodes','public_score_precision'];
const labels={guess_min_interval_sec:'Server-Tippintervall (s)',client_submit_interval_sec:'Client-Tippintervall (s)',guess_lottery_window_sec:'Lotterie-Zeitfenster (s)',guess_lottery_max_accepted:'Max. gezogene Tipps je Task/Fenster (0 = aus)',beacon_hunt_enabled:'Beacon Hunt (0 = aus, 1 = an)',beacon_bonus_weight:'Beacon Treffer-Gewicht (1–10)',sybil_pow_bits:'Anti-Sybil Proof-of-Work (Bit, 0 = aus)',sybil_warmup_sec:'Neue Identität Wartezeit (s)',openai_max_calls_1h:'OpenAI max. Bild-Calls / 1h (0 = aus)',openai_max_calls_24h:'OpenAI max. Bild-Calls / 24h (0 = aus)',openai_max_cost_24h_usd:'OpenAI max. geschätzte Kosten / 24h USD (0 = aus)',openai_budget_reserve_usd:'OpenAI Sicherheitsreserve pro nächstem Call USD',task_range_bits:'Default Zahlenraum (Bit)',active_task_count:'Parallele aktive Tasks',presence_ttl_sec:'Presence TTL (s)',default_max_nodes:'Default Max Nodes',public_score_precision:'Öffentliche Score-Präzision'};
const runtimeKeys=['guess_min_interval_sec','client_submit_interval_sec','guess_lottery_window_sec','guess_lottery_max_accepted','beacon_hunt_enabled','beacon_bonus_weight','sybil_pow_bits','sybil_warmup_sec','openai_max_calls_1h','openai_max_calls_24h','openai_max_cost_24h_usd','openai_budget_reserve_usd','task_range_bits','active_task_count','presence_ttl_sec','default_max_nodes','public_score_precision','place_enabled','place_width','place_height','place_points_per_score','place_pixel_cost'];
const labels={guess_min_interval_sec:'Server-Tippintervall (s)',client_submit_interval_sec:'Client-Tippintervall (s)',guess_lottery_window_sec:'Lotterie-Zeitfenster (s)',guess_lottery_max_accepted:'Max. gezogene Tipps je Task/Fenster (0 = aus)',beacon_hunt_enabled:'Beacon Hunt (0 = aus, 1 = an)',beacon_bonus_weight:'Beacon Treffer-Gewicht (1–10)',sybil_pow_bits:'Anti-Sybil Proof-of-Work (Bit, 0 = aus)',sybil_warmup_sec:'Neue Identität Wartezeit (s)',openai_max_calls_1h:'OpenAI max. Bild-Calls / 1h (0 = aus)',openai_max_calls_24h:'OpenAI max. Bild-Calls / 24h (0 = aus)',openai_max_cost_24h_usd:'OpenAI max. geschätzte Kosten / 24h USD (0 = aus)',openai_budget_reserve_usd:'OpenAI Sicherheitsreserve pro nächstem Call USD',task_range_bits:'Default Zahlenraum (Bit)',active_task_count:'Parallele aktive Tasks',presence_ttl_sec:'Presence TTL (s)',default_max_nodes:'Default Max Nodes',public_score_precision:'Öffentliche Score-Präzision',place_enabled:'Neural Place (0 = aus, 1 = an)',place_width:'Place Breite (Pixel)',place_height:'Place Höhe (Pixel)',place_points_per_score:'Place-Punkte je +1,00 Score',place_pixel_cost:'Place-Kosten je Pixel'};
const msg=s=>$('adminstatus').textContent=s;
const renderAdminDropWatch=items=>{const host=$('adminDropResult');if(!host)return;const rows=Array.isArray(items)?items:[];if(!rows.length){host.textContent=adminDropWatchIDs.length?'Queue-Einträge werden gesucht …':'';return}const order={error:0,generating:1,pending:2,ready:3};rows.sort((a,b)=>(order[a.artifact_status]??9)-(order[b.artifact_status]??9));host.innerHTML=`<div class="admin-drop-watch">${rows.map(x=>{const st=String(x.artifact_status||'unknown'),err=String(x.artifact_error||'').trim(),id=String(x.task_id||'');let info=st==='pending'?'wartet auf Artifact-Worker':st==='generating'?'Bild wird gerade erzeugt':st==='ready'?'Collectible fertig':st==='error'?(err||'Generierung fehlgeschlagen'):st;return `<div class="admin-drop-watch-row ${esc(st)}"><b>${esc(st.toUpperCase())}</b><code>${esc(id.slice(-14))}</code>${(x.artifact_rarity||x.artifact_rarity_override)?`<small>${esc(x.artifact_rarity||x.artifact_rarity_override)}</small>`:''}<span title="${esc(err)}">${esc(info.length>180?info.slice(0,179)+'…':info)}</span>${st==='ready'?`<button type="button" data-drop-open="${esc(id)}">ÖFFNEN</button>`:''}</div>`}).join('')}</div>`;host.querySelectorAll('[data-drop-open]').forEach(b=>b.onclick=()=>openAdminFile(b.dataset.dropOpen,'artifact'))};
const refreshAdminDropWatch=async()=>{clearTimeout(adminDropWatchTimer);adminDropWatchTimer=null;if(!adminDropWatchIDs.length)return;try{const rows=await api(`/api/admin/artifacts/status?ids=${encodeURIComponent(adminDropWatchIDs.join(','))}`,{},true);renderAdminDropWatch(rows);const active=(rows||[]).some(x=>x.artifact_status==='pending'||x.artifact_status==='generating');if(active){adminDropWatchTimer=setTimeout(refreshAdminDropWatch,2000)}else{adminDropWatchIDs=[];setTimeout(()=>load(true,false),0)}}catch(e){const host=$('adminDropResult');if(host)host.textContent='Queue-Status konnte nicht geladen werden: '+(e.message||e);adminDropWatchTimer=setTimeout(refreshAdminDropWatch,4000)}};
@@ -482,20 +543,20 @@ async function runAdmin(){
const restoreDraft=()=>{const scope=draftScope(),values=draft.fields?.[scope]||{};document.querySelectorAll('#settingfields input,#settingfields textarea,#settingfields select').forEach(el=>{if(el.type==='file')return;const k=draftFieldKey(el);if(k&&Object.prototype.hasOwnProperty.call(values,k))el.value=values[k]})};
const clearDraftKeys=keys=>{const scope=draftScope(),values=draft.fields?.[scope];if(values){keys.forEach(k=>delete values[k]);saveDraft()}};
function renderOverview(o){$('overview').innerHTML=`<span><b>${o.connected}</b> verbunden</span><span><b>${o.clients}</b> Identitäten</span><span><b>${o.active_tasks}</b> aktive Tasks</span><span><b>${o.completed_tasks}</b> abgeschlossen</span><span><b>${Number(o.guesses||0).toLocaleString('de-DE')}</b> Tipps</span><span><b>${o.artifacts_ready}</b> Artefakte</span>`}
function renderPerformance(p){const r=p?.runtime||{},w=p?.websocket||{},x=p?.process||{};$('performance').innerHTML=`<span><b>${Number(r.guesses_per_sec||0).toFixed(1)}</b> Guess/s</span><span><b>${Number(r.rejected_per_sec||0).toFixed(1)}</b> Reject/s</span><span><b>${Number(r.improvements_per_sec||0).toFixed(1)}</b> Improve/s</span><span><b>${Number(r.sqlite_writes_per_sec||0).toFixed(1)}</b> SQLite W/s</span><span><b>${Number(w.frames_per_sec||0).toFixed(0)}</b> WS Frames/s</span><span><b>${(Number(w.bytes_per_sec||0)/1048576).toFixed(2)}</b> WS MB/s</span><span><b>${Number(w.dropped_per_sec||0).toFixed(1)}</b> Drops/s</span><span><b>${Number(x.goroutines||0).toLocaleString('de-DE')}</b> Goroutines</span><span><b>${(Number(x.heap_bytes||0)/1048576).toFixed(1)}</b> Heap MB</span>`}
function renderPerformance(p){const r=p?.runtime||{},w=p?.websocket||{},pw=p?.place_websocket||{},x=p?.process||{};$('performance').innerHTML=`<span><b>${Number(r.guesses_per_sec||0).toFixed(1)}</b> Guess/s</span><span><b>${Number(r.rejected_per_sec||0).toFixed(1)}</b> Reject/s</span><span><b>${Number(r.improvements_per_sec||0).toFixed(1)}</b> Improve/s</span><span><b>${Number(r.sqlite_writes_per_sec||0).toFixed(1)}</b> SQLite W/s</span><span><b>${Number(w.frames_per_sec||0).toFixed(0)}</b> WS Frames/s</span><span><b>${(Number(w.bytes_per_sec||0)/1048576).toFixed(2)}</b> WS MB/s</span><span><b>${Number(w.dropped_per_sec||0).toFixed(1)}</b> Drops/s</span><span><b>${Number(pw.connected||0).toLocaleString('de-DE')}</b> Place WS</span><span><b>${Number(pw.frames_per_sec||0).toFixed(0)}</b> Place F/s</span><span><b>${Number(x.goroutines||0).toLocaleString('de-DE')}</b> Goroutines</span><span><b>${(Number(x.heap_bytes||0)/1048576).toFixed(1)}</b> Heap MB</span>`}
async function openAdminFile(taskID,kind){const popup=window.open('','_blank');try{const r=await fetch(`/api/admin/tasks/${encodeURIComponent(taskID)}/${kind}`,{credentials:'same-origin'});if(!r.ok)throw new Error(await responseError(r,'Datei konnte nicht geöffnet werden'));const blob=await r.blob(),u=URL.createObjectURL(blob);if(popup)popup.location=u;else{const a=document.createElement('a');a.href=u;a.target='_blank';a.click()}setTimeout(()=>URL.revokeObjectURL(u),60000)}catch(e){if(popup)popup.close();msg(e.message)}}
function renderTasks(){tasks=Array.isArray(tasks)?tasks:[];$('taskCount').textContent=tasks.length;$('tasks').innerHTML=tasks.length?tasks.map(t=>{const aerr=String(t.artifact_error||'').trim();return `<button data-id="${esc(t.id)}" class="${selected?.id===t.id?'selected':''}"><span><b>${esc(t.display_name||t.id.slice(-12))}</b><small>${esc(t.id.slice(-12))} · ${fmtDate(t.created_at)}</small></span><span><em class="task-state ${esc(t.status)}">${t.paused?'PAUSED':esc(t.status)}</em><small>${t.range_bits} Bit · rev ${t.revision} · ${Number(t.point_count||0).toLocaleString('de-DE')} Clients · ${Number(t.guess_count||0).toLocaleString('de-DE')} Tipps</small></span><span class="artifact-state-wrap"><b class="artifact-state ${esc(t.artifact_status||'')}">${esc(t.artifact_status||'—')}</b>${aerr?`<small class="danger artifact-error-summary" title="${esc(aerr)}">${esc(aerr.length>76?aerr.slice(0,75)+'…':aerr)}</small>`:`<small>${t.artifact_origin==='admin_drop'?`ADMIN DROP${(t.artifact_rarity||t.artifact_rarity_override)?' · '+esc(t.artifact_rarity||t.artifact_rarity_override):''}`:t.retire_after_completion?'AUSLAUFEND':t.parent_task_id?`↳ ${esc(String(t.parent_task_id).slice(-7))}`:'ROOT'}</small>`}<small class="artifactLinks">${t.artifact_uri?`<span data-artifact-task="${esc(t.id)}">Bild</span> · <span data-manifest-task="${esc(t.id)}">Manifest</span>`:''}</small></span></button>`}).join(''):'<div class="empty">Keine Tasks</div>';document.querySelectorAll('#tasks button[data-id]').forEach(b=>b.onclick=e=>{const art=e.target.closest('[data-artifact-task]'),man=e.target.closest('[data-manifest-task]');if(art){e.preventDefault();e.stopPropagation();openAdminFile(art.dataset.artifactTask,'artifact');return}if(man){e.preventDefault();e.stopPropagation();openAdminFile(man.dataset.manifestTask,'manifest');return}openTask(tasks.find(t=>t.id===b.dataset.id))})}
function renderRuntime(){
$('settingfields').innerHTML=`<div class="control-section"><div class="section-title">GLOBAL RUNTIME</div>${runtimeKeys.map(k=>`<label><span>${esc(labels[k])}</span><input type="number" step="any" data-setting="${k}" value="${settings?.[k]??''}"></label>`).join('')}<p class="small">Die Tipp-Lotterie gilt <b>getrennt pro aktivem Task</b>. Bei einem Wert &gt; 0 werden alle gültigen Tipps eines Zeitfensters gesammelt und am Fensterende exakt bis zur eingestellten Menge zufällig gezogen. Nicht gezogene Tipps werden nicht gegen das Ziel geprüft und verändern den Score nicht; ihre Sequenz wird trotzdem verbraucht. <b>0 = Lotterie aus</b>. Änderungen greifen für neu beginnende Fenster.</p><p class="small"><b>Beacon Hunt:</b> Optional wählen Spieler PULSE, FLUX oder ORBIT. Der erste öffentliche drand-Round nach Fensterschluss bestimmt reproduzierbar den Boost-Pfad und die gewichtete Ziehung. Alle Reveal-Daten werden gespeichert und über die Public API nachvollziehbar gemacht.</p><p class="small"><b>Anti-Sybil:</b> Neue Browser-Identitäten lösen einmalig einen Proof-of-Work und warten anschließend die konfigurierte Warmup-Zeit, bevor Tipps gewertet werden. Das erhöht die Kosten massenhafter Identitätserstellung, ersetzt aber keine externe echte Identitätsprüfung.</p><p class="small"><b>OpenAI Circuit Breaker:</b> Vor jedem Bild-Call werden rollierende 1h-/24h-Call-Limits und das geschätzte 24h-Kostenbudget geprüft. Bei Überschreitung bleibt die Gewinnerkarte in der Queue und wird später erneut versucht.</p><p class="small">Die übrigen Defaults gelten global. Task-spezifische Intervalle und Zahlenräume steuerst du im Tab „Task Actions“.</p></div>
<div class="control-section profile-cleanup"><div class="section-title">ALTE PROFILE BEREINIGEN</div><p class="small">Löscht ausschließlich Accounts, die <b>seit mindestens X Zeit inaktiv</b>, aktuell <b>nicht verbunden</b> und <b>niemals Gewinner</b> eines Tasks waren. Gewinner werden unabhängig vom Alter immer geschützt. Zugehörige Punkte, Unlocks und Task-Auswahl werden mit dem Profil entfernt.</p>
$('settingfields').innerHTML=`<div class="control-section"><div class="section-title">GLOBAL RUNTIME</div>${runtimeKeys.map(k=>`<label><span>${esc(labels[k])}</span><input type="number" step="any" data-setting="${k}" value="${settings?.[k]??''}"></label>`).join('')}<p class="small">Die Tipp-Lotterie gilt <b>getrennt pro aktivem Task</b>. Bei einem Wert &gt; 0 werden alle gültigen Tipps eines Zeitfensters gesammelt und am Fensterende exakt bis zur eingestellten Menge zufällig gezogen. Nicht gezogene Tipps werden nicht gegen das Ziel geprüft und verändern den Score nicht; ihre Sequenz wird trotzdem verbraucht. <b>0 = Lotterie aus</b>. Änderungen greifen für neu beginnende Fenster.</p><p class="small"><b>Beacon Hunt:</b> Optional wählen Spieler PULSE, FLUX oder ORBIT. Der erste öffentliche drand-Round nach Fensterschluss bestimmt reproduzierbar den Boost-Pfad und die gewichtete Ziehung. Alle Reveal-Daten werden gespeichert und über die Public API nachvollziehbar gemacht.</p><p class="small"><b>Anti-Sybil:</b> Neue Browser-Identitäten lösen einmalig einen Proof-of-Work und warten anschließend die konfigurierte Warmup-Zeit, bevor Tipps gewertet werden. Das erhöht die Kosten massenhafter Identitätserstellung, ersetzt aber keine externe echte Identitätsprüfung.</p><p class="small"><b>OpenAI Circuit Breaker:</b> Vor jedem Bild-Call werden rollierende 1h-/24h-Call-Limits und das geschätzte 24h-Kostenbudget geprüft. Bei Überschreitung bleibt die Gewinnerkarte in der Queue und wird später erneut versucht.</p><p class="small"><b>Neural Place:</b> Bei aktiviertem Place-Modus ist der Score ein Prozentwert von 0–100. Jede echte Highscore-Steigerung erzeugt <code>Place-Punkte je +1,00 Score</code>; verknüpfte Hosted Worker schreiben dieselben Punkte dem Owner-Wallet gut. Ein Placement – auch das Übermalen eines belegten Feldes – kostet <code>Place-Kosten je Pixel</code>. Breite/Höhe können erweitert oder nur so weit verkleinert werden, dass kein vorhandener Pixel abgeschnitten wird; offene Place-Seiten sollten nach einer Größenänderung neu geladen werden.</p><p class="small">Die übrigen Defaults gelten global. Task-spezifische Intervalle und Zahlenräume steuerst du im Tab „Task Actions“.</p></div>
<div class="control-section profile-cleanup"><div class="section-title">ALTE PROFILE BEREINIGEN</div><p class="small">Löscht ausschließlich Accounts, die <b>seit mindestens X Zeit inaktiv</b>, aktuell <b>nicht verbunden</b>, <b>niemals Gewinner</b> eines Tasks und <b>keine Neural-Place-Teilnehmer</b> sind. Gewinner, verknüpfte Worker/Owner und Identitäten mit verdientem Place-Wert oder Placements werden unabhängig vom Alter geschützt. Zugehörige Hunt-Punkte, Unlocks und Task-Auswahl werden bei löschbaren Profilen mit entfernt.</p>
<div class="cleanup-controls"><label><span>Inaktiv seit mindestens</span><input id="profileCleanupValue" data-draft="profileCleanupValue" type="number" min="1" step="1" value="30"></label><label><span>Einheit</span><select id="profileCleanupUnit" data-draft="profileCleanupUnit"><option value="hours">Stunden</option><option value="days" selected>Tage</option><option value="weeks">Wochen</option></select></label></div>
<div class="cleanup-actions"><button id="previewProfileCleanup">PRÜFEN</button><button id="runProfileCleanup" class="danger-button">PROFILE LÖSCHEN</button></div><div id="profileCleanupResult" class="cleanup-result">Noch nicht geprüft.</div>
</div>`;$('savesettings').style.display='inline-block';$('ensuretasks').style.display='inline-block';
const cleanupSeconds=()=>{const v=Math.max(1,Number($('profileCleanupValue')?.value||0)),unit=$('profileCleanupUnit')?.value||'days',factor=unit==='hours'?3600:unit==='weeks'?7*86400:86400;return Math.round(v*factor)};
const showCleanup=p=>{const box=$('profileCleanupResult');if(!box)return;const eligible=Number(p?.eligible||0),wins=Number(p?.protected_winners||0),online=Number(p?.protected_connected||0),cutoff=p?.cutoff_ms?fmtDate(p.cutoff_ms):'—';box.innerHTML=`<b>${eligible.toLocaleString('de-DE')} löschbar</b><span>· ${wins.toLocaleString('de-DE')} alte Gewinner geschützt · ${online.toLocaleString('de-DE')} aktuell verbundene Accounts geschützt</span><small>Grenze: letzte Aktivität vor ${esc(cutoff)}</small>`;};
const showCleanup=p=>{const box=$('profileCleanupResult');if(!box)return;const eligible=Number(p?.eligible||0),wins=Number(p?.protected_winners||0),place=Number(p?.protected_place||0),online=Number(p?.protected_connected||0),cutoff=p?.cutoff_ms?fmtDate(p.cutoff_ms):'—';box.innerHTML=`<b>${eligible.toLocaleString('de-DE')} löschbar</b><span>· ${wins.toLocaleString('de-DE')} alte Gewinner · ${place.toLocaleString('de-DE')} Place-Teilnehmer · ${online.toLocaleString('de-DE')} aktuell verbundene Accounts geschützt</span><small>Grenze: letzte Aktivität vor ${esc(cutoff)}</small>`;};
const previewCleanup=async()=>{try{captureDraft();const seconds=cleanupSeconds(),p=await api(`/api/admin/profiles/cleanup-preview?inactive_for_seconds=${seconds}`,{},true);showCleanup(p);return p}catch(e){msg(e.message);throw e}};
$('previewProfileCleanup').onclick=()=>previewCleanup().catch(()=>{});
$('runProfileCleanup').onclick=async()=>{try{const p=await previewCleanup();const n=Number(p?.eligible||0);if(!n){msg('Keine passenden inaktiven Nicht-Gewinner-Profile gefunden');return}const value=$('profileCleanupValue').value,unitLabel=$('profileCleanupUnit').selectedOptions[0]?.textContent||'';if(!confirm(`${n.toLocaleString('de-DE')} Profile endgültig löschen?\n\nKriterium: seit mindestens ${value} ${unitLabel} inaktiv, offline und niemals Gewinner.\nGewinner und aktuell verbundene Accounts bleiben geschützt.`))return;const out=await api('/api/admin/profiles/cleanup',{method:'POST',body:JSON.stringify({inactive_for_seconds:cleanupSeconds()})},true);msg(`${Number(out.deleted||0).toLocaleString('de-DE')} alte Profile gelöscht`);showCleanup({...p,eligible:Math.max(0,n-Number(out.deleted||0))});await load(true,false)}catch(e){msg(e.message)}};
$('runProfileCleanup').onclick=async()=>{try{const p=await previewCleanup();const n=Number(p?.eligible||0);if(!n){msg('Keine passenden inaktiven Nicht-Gewinner-Profile gefunden');return}const value=$('profileCleanupValue').value,unitLabel=$('profileCleanupUnit').selectedOptions[0]?.textContent||'';if(!confirm(`${n.toLocaleString('de-DE')} Profile endgültig löschen?\n\nKriterium: seit mindestens ${value} ${unitLabel} inaktiv, offline, niemals Gewinner und ohne Neural-Place-Wert.\nGewinner, Place-Teilnehmer, verknüpfte Worker/Owner und aktuell verbundene Accounts bleiben geschützt.`))return;const out=await api('/api/admin/profiles/cleanup',{method:'POST',body:JSON.stringify({inactive_for_seconds:cleanupSeconds()})},true);msg(`${Number(out.deleted||0).toLocaleString('de-DE')} alte Profile gelöscht`);showCleanup({...p,eligible:Math.max(0,n-Number(out.deleted||0))});await load(true,false)}catch(e){msg(e.message)}};
}
function renderArtifact(){
const ps=providers?.providers||{},preset=settings?.artifact_preset||'legacy',u=artifactUsage||{},cb=u.circuit_breaker||{},recent=Array.isArray(u.recent)?u.recent:[];
@@ -589,4 +650,4 @@ async function runAdmin(){
applyMobileMode(mobileModeEnabled());
const mobileMedia=matchMedia('(max-width: 850px)');mobileMedia.addEventListener?.('change',()=>{if(localStorage.getItem(mobileModeKey)===null)applyMobileMode(autoMobileMode())});
const path=location.pathname;
(path.startsWith('/admin')?runAdmin():path.startsWith('/leaderboard')?runLeaderboard():runUser()).catch(e=>{app.innerHTML=`<pre style="padding:2rem;color:#ff9bad">${esc(e.stack||e.message||e)}</pre>`});
(path.startsWith('/admin')?runAdmin():path.startsWith('/leaderboard')?runLeaderboard():path.startsWith('/place')?runPlace():runUser()).catch(e=>{app.innerHTML=`<pre style="padding:2rem;color:#ff9bad">${esc(e.stack||e.message||e)}</pre>`});
+13
View File
@@ -116,3 +116,16 @@ html.mobile-mode .reference-admin-box,html.mobile-mode .task-style-admin{grid-te
.beacon-choice>small{display:block;opacity:.68;line-height:1.35}
.admin-drop-box,.ownership-box{margin-top:12px}.admin-drop-box #adminDropResult{margin-top:8px;color:#8edbb8}.task-facts{grid-template-columns:repeat(4,minmax(0,1fr))}@media(max-width:720px){.task-facts{grid-template-columns:repeat(2,minmax(0,1fr))}}
.admin-drop-watch{display:grid;gap:5px;margin-top:8px}.admin-drop-watch-row{display:grid;grid-template-columns:78px 112px minmax(0,1fr) auto;gap:7px;align-items:center;padding:7px 8px;border:1px solid rgba(133,200,255,.10);border-radius:9px;background:rgba(255,255,255,.018);font-size:8px}.admin-drop-watch-row b{font-size:7px;letter-spacing:.09em}.admin-drop-watch-row code{font-size:7px;color:#8fbdcf}.admin-drop-watch-row span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#819dac}.admin-drop-watch-row.generating b{color:#8feeff}.admin-drop-watch-row.ready b{color:#7dffc3}.admin-drop-watch-row.error{border-color:rgba(255,95,136,.28)}.admin-drop-watch-row.error b,.admin-drop-watch-row.error span{color:#ff9bad}.admin-drop-watch-row button{padding:5px 7px}@media(max-width:720px){.admin-drop-watch-row{grid-template-columns:70px 1fr}.admin-drop-watch-row span{grid-column:1/-1;white-space:normal}.admin-drop-watch-row button{grid-column:1/-1}}
/* Neural Place — shared progress-funded pixel canvas. */
.place-page{min-height:100%;overflow:hidden;background:radial-gradient(circle at 50% 38%,#091929 0,#030812 52%,#010307 100%)}
.place-top{position:fixed;z-index:20;top:16px;left:16px;right:16px;height:64px;border-radius:18px;display:flex;align-items:center;justify-content:space-between;padding:0 18px}.place-nav{display:flex;align-items:center;gap:8px}.place-nav>a{border:1px solid rgba(82,231,255,.15);border-radius:9px;padding:8px 10px;color:#9bc7d8;text-decoration:none;font-size:9px;font-weight:800;letter-spacing:.1em}.place-nav>a:hover{color:#effcff;background:rgba(82,231,255,.07)}.place-live{display:flex;align-items:center;gap:6px;margin-right:7px;color:#7897aa;font-size:8px;font-weight:900;letter-spacing:.12em}.place-live i{width:7px;height:7px;border-radius:50%;background:#617786}.place-live.on{color:var(--green)}.place-live.on i{background:var(--green);box-shadow:0 0 12px var(--green);animation:pulse 1.5s infinite}
.place-layout{position:fixed;inset:96px 16px 16px;display:grid;grid-template-columns:260px minmax(360px,1fr) 230px;gap:12px;min-height:0}.place-side,.place-stage{min-height:0;border-radius:18px}.place-side{padding:16px;overflow:hidden;display:flex;flex-direction:column}.place-kicker,.place-section-head{font-size:8px;font-weight:900;letter-spacing:.13em;color:#708fa2}.place-balance{padding:15px 0 13px;border-bottom:1px solid rgba(133,200,255,.11)}.place-balance>small{display:block;color:#638297;font-size:7px;letter-spacing:.16em}.place-balance>strong{display:block;margin:5px 0 4px;font-size:34px;line-height:1;color:#e9fbff;letter-spacing:-.05em;font-variant-numeric:tabular-nums}.place-balance>span{color:#8aa7b8;font-size:9px}.place-balance>span b{color:var(--cyan)}.place-wallet-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin:12px 0}.place-wallet-grid span{padding:8px;border:1px solid rgba(133,200,255,.09);border-radius:10px;background:rgba(255,255,255,.018)}.place-wallet-grid small{display:block;color:#5f8195;font-size:6px;letter-spacing:.1em}.place-wallet-grid b{display:block;margin-top:4px;font-size:11px;color:#dff8ff}.place-economy{padding:11px;border:1px solid rgba(82,231,255,.12);border-radius:12px;background:rgba(82,231,255,.025)}.place-economy p{margin:7px 0 9px;color:#7899aa;font-size:8px;line-height:1.45}.place-economy>div{display:flex;align-items:baseline;justify-content:space-between;padding:5px 0;border-top:1px solid rgba(133,200,255,.07)}.place-economy>div b{font-size:10px;color:#dffcff}.place-economy>div small{font-size:7px;color:#66879a}.place-workers,.place-earnings{margin-top:14px;min-height:0}.place-earnings{display:flex;flex-direction:column;flex:1}.place-section-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px}.place-section-head small{font-size:7px;color:#5f8094}.place-worker-list{display:flex;gap:5px;flex-wrap:wrap}.place-worker-list code{max-width:100%;overflow:hidden;text-overflow:ellipsis;padding:5px 6px;border-radius:7px;background:rgba(183,117,255,.065);border:1px solid rgba(183,117,255,.13);color:#c8a7eb;font-size:7px}.place-earning-list{overflow:auto;min-height:0;padding-right:3px}.place-earning{display:grid;grid-template-columns:44px 1fr;gap:7px;align-items:center;padding:7px 0;border-bottom:1px solid rgba(133,200,255,.07)}.place-source{font-size:6px;font-weight:900;letter-spacing:.08em;color:#8feeff}.place-source.worker{color:#c7a1ff}.place-earning b{font-size:9px;color:#dffff3}.place-earning small{display:block;margin-top:2px;color:#628397;font-size:7px;line-height:1.35}
.place-stage{display:grid;grid-template-rows:52px minmax(0,1fr) auto;overflow:hidden;background:rgba(3,9,17,.72)}.place-canvas-toolbar{display:grid;grid-template-columns:minmax(130px,1fr) auto auto;gap:14px;align-items:center;padding:0 13px;border-bottom:1px solid rgba(133,200,255,.1)}.place-canvas-toolbar>div:first-child b{display:block;margin-top:3px;font-size:10px;color:#dffaff}.place-canvas-stats{display:flex;gap:14px;color:#68889a;font-size:7px}.place-canvas-stats b{color:#cfeaf3;font-size:9px}.place-tools{display:flex;gap:4px}.place-tools button{min-width:34px;padding:6px 8px}.place-viewport{position:relative;min-height:0;overflow:hidden;background-color:#07101a;background-image:linear-gradient(45deg,rgba(255,255,255,.018) 25%,transparent 25%),linear-gradient(-45deg,rgba(255,255,255,.018) 25%,transparent 25%),linear-gradient(45deg,transparent 75%,rgba(255,255,255,.018) 75%),linear-gradient(-45deg,transparent 75%,rgba(255,255,255,.018) 75%);background-size:20px 20px;background-position:0 0,0 10px,10px -10px,-10px 0}.place-viewport canvas{position:absolute;inset:0;width:100%;height:100%;display:block;cursor:crosshair;touch-action:none}.place-hint{position:absolute;left:50%;bottom:10px;transform:translateX(-50%);pointer-events:none;padding:6px 9px;border:1px solid rgba(133,200,255,.1);background:rgba(2,7,13,.75);border-radius:999px;color:#628398;font-size:7px;letter-spacing:.04em;white-space:nowrap}.place-hover{position:absolute;pointer-events:none;z-index:5;min-width:85px;padding:6px 8px;border:1px solid rgba(82,231,255,.2);border-radius:8px;background:rgba(2,7,14,.94);box-shadow:0 8px 22px rgba(0,0,0,.35)}.place-hover b{display:block;color:#ecfbff;font-size:9px}.place-hover small{display:block;margin-top:2px;color:#7493a5;font-size:7px}
.place-compose{display:grid;grid-template-columns:minmax(220px,1.35fr) minmax(150px,.8fr) auto;gap:12px;align-items:end;padding:12px;border-top:1px solid rgba(133,200,255,.1);background:rgba(2,7,14,.72)}.place-palette{display:grid;grid-template-columns:repeat(16,minmax(13px,1fr));gap:4px;margin-top:6px}.place-swatch{aspect-ratio:1;padding:2px!important;border-radius:5px!important;border-color:rgba(255,255,255,.09)!important;background:transparent!important}.place-swatch i{display:block;width:100%;height:100%;border-radius:3px;background:var(--swatch);box-shadow:inset 0 0 0 1px rgba(255,255,255,.13)}.place-swatch.active{border-color:#fff!important;box-shadow:0 0 0 1px rgba(82,231,255,.8),0 0 13px rgba(82,231,255,.28)}.place-selection{min-width:0;padding:8px 10px;border:1px solid rgba(133,200,255,.09);border-radius:10px;background:rgba(255,255,255,.018)}.place-selection small{display:block;font-size:6px;color:#648398;letter-spacing:.11em}.place-selection b{display:block;margin-top:3px;font-size:10px}.place-selection span{display:block;margin-top:3px;color:#718f9f;font-size:7px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.place-compose>button{height:42px;padding:0 15px;color:#c9fff0;border-color:rgba(93,255,189,.28);background:rgba(93,255,189,.07)}.place-compose>button:not(:disabled):hover{background:rgba(93,255,189,.13)}.place-compose>button span{color:#7cbba4;font-size:8px}
.place-right{padding-right:10px}.place-feed{overflow:auto;min-height:0;flex:1;padding-right:4px}.place-feed-row{display:grid;grid-template-columns:12px 1fr;gap:8px;align-items:center;padding:7px 0;border-bottom:1px solid rgba(133,200,255,.065)}.place-feed-row>i{width:11px;height:11px;border-radius:3px;background:var(--feed-color);box-shadow:0 0 10px color-mix(in srgb,var(--feed-color) 40%,transparent)}.place-feed-row b{display:block;font-size:8px;color:#cfe5ef}.place-feed-row small{display:block;margin-top:2px;font-size:7px;color:#607f91}.place-rules{margin-top:11px;padding:10px;border:1px solid rgba(255,180,82,.12);border-radius:10px;background:rgba(255,180,82,.025)}.place-rules p{margin:6px 0 0;color:#7f92a0;font-size:7px;line-height:1.45}.place-toast{position:fixed;z-index:50;left:50%;bottom:24px;transform:translateX(-50%);padding:10px 14px;border:1px solid rgba(93,255,189,.28);border-radius:10px;background:rgba(4,20,17,.95);color:#bfffe4;font-size:9px;font-weight:800;box-shadow:0 14px 40px rgba(0,0,0,.45)}.place-toast.bad{border-color:rgba(255,95,136,.32);background:rgba(25,6,12,.96);color:#ffb0c2}
@media(max-width:1180px){.place-layout{grid-template-columns:220px minmax(340px,1fr)}.place-right{display:none}.place-palette{grid-template-columns:repeat(8,1fr)}.place-compose{grid-template-columns:minmax(180px,1fr) minmax(145px,.8fr) auto}}
@media(max-width:820px){.place-page{overflow:auto}.place-top{top:8px;left:8px;right:8px;height:58px;padding:0 11px}.place-top .brand small{display:none}.place-nav{gap:4px}.place-live{display:none}.place-nav>a{padding:7px;font-size:7px}.place-layout{position:absolute;inset:78px 8px auto;display:flex;flex-direction:column;gap:8px;padding-bottom:8px}.place-stage{order:1;height:72vh;min-height:520px}.place-left{order:2;overflow:visible}.place-right{display:flex;order:3;max-height:380px}.place-canvas-toolbar{grid-template-columns:1fr auto}.place-canvas-stats{display:none}.place-compose{grid-template-columns:1fr;align-items:stretch}.place-palette{grid-template-columns:repeat(16,1fr)}.place-selection{display:none}.place-compose>button{width:100%}.place-side{padding:13px}.place-earnings{max-height:240px}.place-hint{font-size:6px}.place-balance>strong{font-size:28px}}
@media(max-width:520px){.place-stage{height:68vh;min-height:470px}.place-palette{grid-template-columns:repeat(8,1fr);gap:5px}.place-canvas-toolbar{height:48px;padding:0 8px}.place-tools button{min-width:30px;padding:5px}.place-compose{padding:9px}.place-layout{}.place-top .brand strong{font-size:10px}.place-nav>a:first-of-type{display:none}}
.signal-metrics{grid-template-columns:repeat(4,1fr)}
.identity-actions{flex-wrap:wrap}.identity-actions>*{min-width:calc(33.333% - 6px)}