RC-7
release-tag / release-image (push) Failing after 2m44s

This commit is contained in:
2026-08-11 16:58:07 +02:00
parent 185ccf1101
commit bcfbef390f
44 changed files with 4778 additions and 172 deletions
+35 -13
View File
@@ -52,16 +52,26 @@ function requireWebCrypto(){
}
throw new Error('WebCrypto ist in diesem Browser nicht verfügbar. Bitte verwende einen aktuellen Browser mit aktivierter WebCrypto-Unterstützung.');
}
async function ensureIdentity(){const raw=localStorage.getItem(identityKey);if(raw){requireWebCrypto();return JSON.parse(raw)}const subtle=requireWebCrypto();const kp=await subtle.generateKey({name:'ECDSA',namedCurve:'P-256'},true,['sign','verify']);const b={version:1,publicJwk:await subtle.exportKey('jwk',kp.publicKey),privateJwk:await subtle.exportKey('jwk',kp.privateKey)};localStorage.setItem(identityKey,JSON.stringify(b));return b}
async function clientId(pub){const subtle=requireWebCrypto();const s=`${pub.kty}|${pub.crv}|${pub.x}|${pub.y}`;return b64u(await subtle.digest('SHA-256',new TextEncoder().encode(s)))}
async function validateIdentityBundle(b){
const subtle=requireWebCrypto();
if(!b||Number(b.version)!==1||b.publicJwk?.kty!=='EC'||b.publicJwk?.crv!=='P-256'||b.privateJwk?.kty!=='EC'||b.privateJwk?.crv!=='P-256'||!b.privateJwk?.d)throw new Error('Ungültige Neural-Hunt-Identität');
const pub=await subtle.importKey('jwk',b.publicJwk,{name:'ECDSA',namedCurve:'P-256'},false,['verify']);
const priv=await subtle.importKey('jwk',b.privateJwk,{name:'ECDSA',namedCurve:'P-256'},false,['sign']);
const probe=crypto.getRandomValues(new Uint8Array(32)),sig=await subtle.sign({name:'ECDSA',hash:'SHA-256'},priv,probe);
if(!await subtle.verify({name:'ECDSA',hash:'SHA-256'},pub,sig,probe))throw new Error('Public/Private Key der Identität passen nicht zusammen');
return clientId(b.publicJwk);
}
async function ensureIdentity(){const raw=localStorage.getItem(identityKey);if(raw){requireWebCrypto();const b=JSON.parse(raw);await validateIdentityBundle(b);return b}const subtle=requireWebCrypto();const kp=await subtle.generateKey({name:'ECDSA',namedCurve:'P-256'},true,['sign','verify']);const b={version:1,publicJwk:await subtle.exportKey('jwk',kp.publicKey),privateJwk:await subtle.exportKey('jwk',kp.privateKey)};await validateIdentityBundle(b);localStorage.setItem(identityKey,JSON.stringify(b));return b}
async function sign(message){const subtle=requireWebCrypto();const b=await ensureIdentity();const k=await subtle.importKey('jwk',b.privateJwk,{name:'ECDSA',namedCurve:'P-256'},false,['sign']);return b64u(await subtle.sign({name:'ECDSA',hash:'SHA-256'},k,new TextEncoder().encode(message)))}
async function responseError(r,fallback){try{const b=await r.json();return b?.error?`${fallback}: ${b.error}`:`${fallback} (HTTP ${r.status})`}catch{return `${fallback} (HTTP ${r.status})`}}
function zeroBits(bytes){let n=0;for(const x of bytes){if(x===0){n+=8;continue}for(let m=0x80;m&&!(x&m);m>>=1)n++;break}return n}
async function solveIdentityProof(challenge,cid,bits){bits=Number(bits||0);if(bits<=0)return '';const subtle=requireWebCrypto(),enc=new TextEncoder(),prefix=`nh-pow-v1|${challenge}|${cid}|`;let counter=0;const batch=96;while(true){const nums=Array.from({length:batch},(_,i)=>counter+i),hashes=await Promise.all(nums.map(n=>subtle.digest('SHA-256',enc.encode(prefix+n))));for(let i=0;i<hashes.length;i++)if(zeroBits(new Uint8Array(hashes[i]))>=bits)return String(nums[i]);counter+=batch;if(counter%3072===0)await new Promise(r=>setTimeout(r,0))}}
async function loginIdentity(){const b=await ensureIdentity();const cr=await fetch('/api/auth/challenge',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({public_jwk:b.publicJwk})});if(!cr.ok)throw new Error(await responseError(cr,'Challenge fehlgeschlagen'));const c=await cr.json(),bits=Number(c.proof_of_work_bits||0);if(bits>0&&$('status'))$('status').textContent=`Neue Identität wird geprüft · ${bits}-Bit Proof-of-Work …`;const proof_of_work_counter=await solveIdentityProof(c.challenge,c.client_id,bits),signature=await sign(`login|${c.challenge}|${c.client_id}`);const r=await fetch('/api/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({public_jwk:b.publicJwk,challenge:c.challenge,signature,proof_of_work_counter})});if(!r.ok)throw new Error(await responseError(r,'Login fehlgeschlagen'));return r.json()}
async function deterministicGuess(taskID,seed,cid,seq,bits){const subtle=requireWebCrypto();const h=new Uint8Array(await subtle.digest('SHA-256',new TextEncoder().encode(`${taskID}|${seed}|${cid}|${seq}`)));let n=0n;for(const x of h)n=(n<<8n)|BigInt(x);return (n%(1n<<BigInt(bits))).toString()}
async function exportIdentity(passphrase){const subtle=requireWebCrypto();const b=await ensureIdentity();const salt=crypto.getRandomValues(new Uint8Array(16));const iv=crypto.getRandomValues(new Uint8Array(12));const base=await subtle.importKey('raw',new TextEncoder().encode(passphrase),'PBKDF2',false,['deriveKey']);const aes=await subtle.deriveKey({name:'PBKDF2',salt,iterations:250000,hash:'SHA-256'},base,{name:'AES-GCM',length:256},false,['encrypt']);const ct=await subtle.encrypt({name:'AES-GCM',iv},aes,new TextEncoder().encode(JSON.stringify(b)));return JSON.stringify({version:1,salt:b64u(salt),iv:b64u(iv),ciphertext:b64u(ct)},null,2)}
async function importIdentity(raw,passphrase){const subtle=requireWebCrypto();const x=JSON.parse(raw);const base=await subtle.importKey('raw',new TextEncoder().encode(passphrase),'PBKDF2',false,['deriveKey']);const aes=await subtle.deriveKey({name:'PBKDF2',salt:ub64(x.salt),iterations:250000,hash:'SHA-256'},base,{name:'AES-GCM',length:256},false,['decrypt']);const pt=await subtle.decrypt({name:'AES-GCM',iv:ub64(x.iv)},aes,ub64(x.ciphertext));const b=JSON.parse(new TextDecoder().decode(pt));localStorage.setItem(identityKey,JSON.stringify(b));return b}
const identityKdfIterations=250000;
async function exportIdentity(passphrase){const subtle=requireWebCrypto();if(String(passphrase||'').length<12)throw new Error('Die Export-Passphrase muss mindestens 12 Zeichen lang sein.');const b=await ensureIdentity(),cid=await validateIdentityBundle(b);const salt=crypto.getRandomValues(new Uint8Array(16));const iv=crypto.getRandomValues(new Uint8Array(12));const base=await subtle.importKey('raw',new TextEncoder().encode(passphrase),'PBKDF2',false,['deriveKey']);const aes=await subtle.deriveKey({name:'PBKDF2',salt,iterations:identityKdfIterations,hash:'SHA-256'},base,{name:'AES-GCM',length:256},false,['encrypt']);const ct=await subtle.encrypt({name:'AES-GCM',iv},aes,new TextEncoder().encode(JSON.stringify(b)));return JSON.stringify({version:1,format:'neuralhunt-identity-export',clientId:cid,kdf:'PBKDF2-HMAC-SHA256',iterations:identityKdfIterations,cipher:'AES-256-GCM',salt:b64u(salt),iv:b64u(iv),ciphertext:b64u(ct)},null,2)}
async function importIdentity(raw,passphrase){const subtle=requireWebCrypto();const x=JSON.parse(raw);if(!x?.ciphertext||!x?.salt||!x?.iv)throw new Error('Bitte einen verschlüsselten Neural-Hunt-Identitäts-Export auswählen.');if(x.format&&x.format!=='neuralhunt-identity-export')throw new Error('Nicht unterstütztes Identitätsformat.');if(x.kdf&&x.kdf!=='PBKDF2-HMAC-SHA256')throw new Error('Nicht unterstützte KDF.');if(x.cipher&&x.cipher!=='AES-256-GCM')throw new Error('Nicht unterstützte Verschlüsselung.');const iterations=Number(x.iterations||identityKdfIterations);if(iterations<100000||iterations>2000000)throw new Error('Nicht unterstützte KDF-Konfiguration.');const base=await subtle.importKey('raw',new TextEncoder().encode(passphrase),'PBKDF2',false,['deriveKey']);const aes=await subtle.deriveKey({name:'PBKDF2',salt:ub64(x.salt),iterations,hash:'SHA-256'},base,{name:'AES-GCM',length:256},false,['decrypt']);let pt;try{pt=await subtle.decrypt({name:'AES-GCM',iv:ub64(x.iv)},aes,ub64(x.ciphertext))}catch{throw new Error('Identität konnte nicht entschlüsselt werden: falsche Passphrase oder beschädigter Export.')}const b=JSON.parse(new TextDecoder().decode(pt)),cid=await validateIdentityBundle(b);if(x.clientId&&x.clientId!==cid)throw new Error('Client-ID im Export stimmt nicht mit dem Schlüssel überein.');return {bundle:b,clientId:cid}}
function hashInt(s){let h=2166136261>>>0;s=String(s||'');for(let i=0;i<s.length;i++){h^=s.charCodeAt(i);h=Math.imul(h,16777619)}return h>>>0}
function pseudo(s,o=0){return ((Math.sin((hashInt(`${s}:${o}`)+1)*0.00000137+o*12.345)*43758.5453123)%1+1)%1}
@@ -302,8 +312,9 @@ function userShell(){
<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 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">Export</button><label class="button">Import<input id="importid" hidden type="file" accept="application/json"></label></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><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>
@@ -316,7 +327,7 @@ function userShell(){
<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><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><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>
@@ -325,11 +336,12 @@ function userShell(){
}
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;
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';
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()};
const status=(s,mode='living')=>{if($('status'))$('status').textContent=s;const m=$('visualMode');if(m){m.className=`mode-status ${mode}`;$('modeTitle').textContent=mode==='thinking'?'GUESS':mode==='researching'?'WIN':task?.paused?'PAUSED':'LIVING';$('modeDetail').textContent=s}};
const syncBeacon=()=>{const on=Number(task?.beacon_hunt_enabled||0)===1&&Number(task?.guess_lottery_max_accepted||0)>0,box=$('beaconChoice');if(!box)return;box.classList.toggle('hidden',!on);box.querySelectorAll('[data-beacon-path]').forEach(b=>setActive(b,b.dataset.beaconPath===beaconPath));if(on)$('beaconMeta').textContent=`Treffer = Gewicht ×${Number(task.beacon_bonus_weight||2)}`};
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};
@@ -361,16 +373,26 @@ async function runUser(){
if(e.code==='task_config_changed')await refreshTaskConfig(true);
nextGuessAt=Date.now()+1500;status(e.code==='presence_required'?'Live-Verbindung wird automatisch wiederhergestellt …':'Client wird automatisch synchronisiert …')
}
async function submit(){if(!task||submitting)return;if(!wsReady()){scheduleWSReconnect();nextGuessAt=Date.now()+1000;status('Live-Verbindung wird wiederhergestellt …');return}submitting=true;status('signiert Tipp …','thinking');try{const current=await api('/api/tasks/current');if(current.id!==task.id){await showLanding();return}task=current;if(task.paused){status('Task pausiert');nextGuessAt=0;return}const seq=task.next_seq,guess=await deterministicGuess(task.id,task.public_seed,cid,seq,task.range_bits),signature=await sign(`guess|${task.id}|${seq}|${guess}`);if(Number(task.guess_lottery_max_accepted||0)>0)status(`wartet auf Losziehung · max. ${Number(task.guess_lottery_max_accepted).toLocaleString('de-DE')} Tipps / ${Number(task.guess_lottery_window_sec||60)}s`,'thinking');const correct=await api(`/api/tasks/${task.id}/guess`,{method:'POST',body:JSON.stringify({seq,guess,signature})});nextGuessAt=Date.now()+Math.max(1,task.client_submit_interval_sec)*1000;status(correct?'Treffer — Task gelöst!':Number(task.guess_lottery_max_accepted||0)>0?'Tipp gezogen & geprüft':'Tipp akzeptiert',correct?'researching':'living');await Promise.all([refreshMe(),refreshLeaders()]);if(correct)setTimeout(()=>showLanding(),1300)}catch(e){if(e.status===409){await recover409(e);return}nextGuessAt=Date.now()+Math.max(2,task?.client_submit_interval_sec||11)*1000;if(e.code==='identity_warmup'){const wait=Math.max(1,Number(e.data?.retry_after_sec||task?.client_submit_interval_sec||15));nextGuessAt=Date.now()+wait*1000;status(`Anti-Sybil-Wartezeit · ${wait}s`,'living');return}if(e.code==='lottery_not_selected'){status('Tipp diesmal nicht gezogen · nächstes Los folgt','living');return}if(e.code==='lottery_full'){status('Losfenster voll · nächster Versuch folgt','living');return}status(e.message||'Tipp fehlgeschlagen')}finally{submitting=false}}
async function submit(){if(!task||submitting)return;if(!wsReady()){scheduleWSReconnect();nextGuessAt=Date.now()+1000;status('Live-Verbindung wird wiederhergestellt …');return}submitting=true;status('signiert Tipp …','thinking');try{const current=await api('/api/tasks/current');if(current.id!==task.id){await showLanding();return}task=current;syncBeacon();if(task.paused){status('Task pausiert');nextGuessAt=0;return}const seq=task.next_seq,guess=await deterministicGuess(task.id,task.public_seed,cid,seq,task.range_bits),beaconOn=Number(task.beacon_hunt_enabled||0)===1&&Number(task.guess_lottery_max_accepted||0)>0,msg=beaconOn?`guess|${task.id}|${seq}|${guess}|${beaconPath}`:`guess|${task.id}|${seq}|${guess}`,signature=await sign(msg);if(Number(task.guess_lottery_max_accepted||0)>0)status(beaconOn?`Beacon ${beaconPath} committed · wartet auf externen Draw`:`wartet auf Losziehung · max. ${Number(task.guess_lottery_max_accepted).toLocaleString('de-DE')} Tipps / ${Number(task.guess_lottery_window_sec||60)}s`,'thinking');const correct=await api(`/api/tasks/${task.id}/guess`,{method:'POST',body:JSON.stringify({seq,guess,signature,beacon_path:beaconOn?beaconPath:''})});nextGuessAt=Date.now()+Math.max(1,task.client_submit_interval_sec)*1000;if(beaconOn){try{const d=await api(`/api/public/beacon/${encodeURIComponent(task.id)}/latest`);$('beaconLast').textContent=`Dein Pfad ${beaconPath} · Boost ${d.boosted_path} · drand #${d.beacon_round}`;status(correct?'Treffer — Task gelöst!':`Draw ${d.boosted_path} · Tipp gezogen & geprüft`,correct?'researching':'living')}catch{status(correct?'Treffer — Task gelöst!':'Beacon-Tipp gezogen & geprüft',correct?'researching':'living')}}else status(correct?'Treffer — Task gelöst!':Number(task.guess_lottery_max_accepted||0)>0?'Tipp gezogen & geprüft':'Tipp akzeptiert',correct?'researching':'living');await Promise.all([refreshMe(),refreshLeaders()]);if(correct)setTimeout(()=>showLanding(),1300)}catch(e){if(e.status===409){await recover409(e);return}nextGuessAt=Date.now()+Math.max(2,task?.client_submit_interval_sec||11)*1000;if(e.code==='identity_warmup'){const wait=Math.max(1,Number(e.data?.retry_after_sec||task?.client_submit_interval_sec||15));nextGuessAt=Date.now()+wait*1000;status(`Anti-Sybil-Wartezeit · ${wait}s`,'living');return}if(e.code==='lottery_not_selected'){if(e.data?.boosted_path&&$('beaconLast'))$('beaconLast').textContent=`Dein Pfad ${e.data.chosen_path} · Boost ${e.data.boosted_path} · Gewicht ×${e.data.weight||1} · drand #${e.data.beacon_round}`;status('Tipp diesmal nicht gezogen · nächstes Los folgt','living');return}if(e.code==='beacon_unavailable'){status('Randomness Beacon nicht erreichbar · kein Tipp ausgewertet','living');return}if(e.code==='lottery_full'){status('Losfenster voll · nächster Versuch folgt','living');return}status(e.message||'Tipp fehlgeschlagen')}finally{submitting=false}}
function openWS(force=false){if(!task||$('taskLanding').classList.contains('visible'))return;clearWSReconnect();if(ws&&(ws.readyState===WebSocket.OPEN||ws.readyState===WebSocket.CONNECTING)){if(!force)return;const old=ws;old._plannedClose=true;try{old.close(1000,'reconnect')}catch{}}const proto=location.protocol==='https:'?'wss':'ws',mx=+$('maxnodes').value||task.default_max_nodes||2000,socket=new WebSocket(`${proto}://${location.host}/api/ws?max_nodes=${encodeURIComponent(mx)}`,['neuralhunt.v1',`nh-auth.${getToken()}`]);ws=socket;socket.onopen=()=>{if(ws!==socket)return;wsBackoff=500;status(task?.paused?'Task pausiert':'verbunden')};socket.onmessage=async ev=>{if(ws!==socket)return;const e=JSON.parse(ev.data);if(e.type==='snapshot'){points=Array.isArray(e.data)?e.data:[];render()}else if(e.type==='point'){const p=e.data,i=points.findIndex(x=>x.client_id===p.client_id);if(i<0)points.push(p);else points[i]=p;render()}else if(e.type==='points'){for(const p of (Array.isArray(e.data)?e.data:[])){const i=points.findIndex(x=>x.client_id===p.client_id);if(i<0)points.push(p);else points[i]=p}render()}else if(e.type==='task_changed'){await refreshTaskConfig(true);await Promise.all([refreshMe(),refreshLeaders()])}else if(e.type==='task_completed'){status('Task abgeschlossen — Folge-Task ist bereit','researching');setTimeout(()=>showLanding(),1300)}};socket.onclose=e=>{if(ws===socket)ws=null;if(!socket._plannedClose&&task&&!$('taskLanding').classList.contains('visible')){status('Live-Verbindung unterbrochen · verbinde automatisch neu …');scheduleWSReconnect()}};socket.onerror=()=>{if(!socket._plannedClose&&ws===socket)status('WebSocket-Fehler · Reconnect folgt automatisch')}}
async function enterTask(taskID){if(landingBusy)return;landingBusy=true;try{await stopTaskSession();task=await api('/api/tasks/select',{method:'POST',body:JSON.stringify({task_id:taskID})});$('taskLanding').classList.remove('visible');$('taskid').textContent=task.display_name?shortID(task.display_name,16):shortID(task.id.slice(-12),12);let max=+$('maxnodes').value||clamp(Number(task.default_max_nodes||2000),100,25000);if(document.documentElement.classList.contains('mobile-mode'))max=Math.min(max,1000);$('maxnodes').value=max;$('maxnodesvalue').textContent=max.toLocaleString('de-DE');points=await api(`/api/tasks/${task.id}/points?limit=${Math.min(10000,Math.max(300,max*3))}`);points=Array.isArray(points)?points:[];render();await Promise.all([refreshMe(),refreshLeaders()]);syncMobile();openWS();status(task.paused?'Task pausiert':`${task.range_bits} Bit · verbunden`);nextGuessAt=task.paused?0:Date.now()+Math.max(1,task.client_submit_interval_sec)*1000;scheduler=setInterval(()=>{if(task&&!task.paused&&nextGuessAt&&Date.now()>=nextGuessAt)submit()},300);countdownTimer=setInterval(countdown,250);countdown()}catch(e){status(e.message||'Task konnte nicht gestartet werden');$('taskLanding').classList.add('visible')}finally{landingBusy=false}}
async function enterTask(taskID){if(landingBusy)return;landingBusy=true;try{await stopTaskSession();task=await api('/api/tasks/select',{method:'POST',body:JSON.stringify({task_id:taskID})});$('taskLanding').classList.remove('visible');syncBeacon();$('taskid').textContent=task.display_name?shortID(task.display_name,16):shortID(task.id.slice(-12),12);let max=+$('maxnodes').value||clamp(Number(task.default_max_nodes||2000),100,25000);if(document.documentElement.classList.contains('mobile-mode'))max=Math.min(max,1000);$('maxnodes').value=max;$('maxnodesvalue').textContent=max.toLocaleString('de-DE');points=await api(`/api/tasks/${task.id}/points?limit=${Math.min(10000,Math.max(300,max*3))}`);points=Array.isArray(points)?points:[];render();await Promise.all([refreshMe(),refreshLeaders()]);syncMobile();openWS();status(task.paused?'Task pausiert':`${task.range_bits} Bit · verbunden`);nextGuessAt=task.paused?0:Date.now()+Math.max(1,task.client_submit_interval_sec)*1000;scheduler=setInterval(()=>{if(task&&!task.paused&&nextGuessAt&&Date.now()>=nextGuessAt)submit()},300);countdownTimer=setInterval(countdown,250);countdown()}catch(e){status(e.message||'Task konnte nicht gestartet werden');$('taskLanding').classList.add('visible')}finally{landingBusy=false}}
try{const id=await ensureIdentity();cid=await clientId(id.publicJwk);$('cid').textContent=cid;$('landingCid').textContent=cid;await ensureSession();syncMobile();await Promise.all([refreshLeaders()]);await showLanding()}catch(e){status(e.message||'Startfehler');$('taskCards').innerHTML=`<div class="task-card-empty glass"><b>Startfehler</b><span>${esc(e.message||'')}</span></div>`}
$('landingRefresh').onclick=()=>showLanding();$('chooseTask').onclick=()=>showLanding();
document.querySelectorAll('[data-beacon-path]').forEach(b=>b.onclick=()=>{beaconPath=b.dataset.beaconPath;localStorage.setItem('neuralhunt.beaconPath',beaconPath);syncBeacon();status(`Beacon-Pfad ${beaconPath} gewählt`) });
$('maxnodes').addEventListener('input',e=>{$('maxnodesvalue').textContent=Number(e.target.value).toLocaleString('de-DE');render()});
$('toggleMobile').onclick=()=>toggleMobileMode();$('toggleDetails').onclick=()=>{detailsOpen=!detailsOpen;$('signalPanel').classList.toggle('expanded',detailsOpen);setActive('toggleDetails',detailsOpen)};window.addEventListener('neuralhunt-mobile-mode',syncMobile);
$('toggleProximity').onclick=()=>{map.proximityFocus=!map.proximityFocus;$('toggleProximity').textContent=map.proximityFocus?'TARGET FIELD':'RAW 3D';setActive('toggleProximity',map.proximityFocus)};$('toggleRotate').onclick=()=>{map.autoRotate=!map.autoRotate;setActive('toggleRotate',map.autoRotate)};$('toggleLabels').onclick=()=>{map.labels=!map.labels;setActive('toggleLabels',map.labels)};$('toggleEdges').onclick=()=>{map.edges=!map.edges;setActive('toggleEdges',map.edges)};$('toggleShells').onclick=()=>{map.shells=!map.shells;setActive('toggleShells',map.shells)};$('toggleLOD').onclick=()=>{map.lodEnabled=!map.lodEnabled;map.rebuild();setActive('toggleLOD',map.lodEnabled)};$('toggleEco').onclick=()=>{map.eco=!map.eco;map.resize();setActive('toggleEco',map.eco)};$('resetView').onclick=()=>map.resetView();
$('exportid').onclick=async()=>{const p=prompt('Passphrase für den verschlüsselten Identitäts-Export');if(!p)return;try{const text=await exportIdentity(p),a=document.createElement('a');a.href=URL.createObjectURL(new Blob([text],{type:'application/json'}));a.download=`neuralhunt-identity-${cid.slice(0,10)}.json`;a.click();setTimeout(()=>URL.revokeObjectURL(a.href),5000)}catch(e){status(e.message||'Export fehlgeschlagen')}};
$('importid').onchange=async e=>{const f=e.target.files?.[0];if(!f)return;const p=prompt('Passphrase für diesen Identitäts-Export');if(!p)return;try{await importIdentity(await f.text(),p);clearToken();location.reload()}catch(err){status(err.message||'Import fehlgeschlagen')}};
async function downloadMyNFT(n){const r=await fetch(n.download_uri,{headers:{Authorization:'Bearer '+getToken()},credentials:'same-origin'});if(!r.ok)throw new Error(await responseError(r,'Original konnte nicht geladen werden'));const blob=await r.blob(),a=document.createElement('a'),ext=(blob.type==='image/svg+xml'?'.svg':blob.type==='image/png'?'.png':blob.type==='image/webp'?'.webp':blob.type==='image/jpeg'?'.jpg':'');a.href=URL.createObjectURL(blob);a.download=`neuralhunt-${n.task_id}${ext}`;a.click();setTimeout(()=>URL.revokeObjectURL(a.href),5000)}
async function renderOwnedNFTs(host){host.innerHTML='<span class="identity-nft-empty">lade …</span>';try{const items=await api('/api/me/artifacts?limit=24');host.innerHTML=items?.length?items.map(n=>`<div class="identity-nft-row"><div><b>${esc(n.display_name||'WINNER NFT')}</b><small>${esc(shortID(n.task_id,12))} · ${Number(n.range_bits||0)} Bit · ${esc(fmtDate(n.completed_at))}</small></div><button data-my-nft="${esc(n.task_id)}">ORIGINAL</button></div>`).join(''):'<span class="identity-nft-empty">Noch keine fertigen Gewinner-Artefakte für diese Identität.</span>';host.querySelectorAll('[data-my-nft]').forEach(b=>b.onclick=async()=>{const n=items.find(x=>x.task_id===b.dataset.myNft);if(!n)return;b.disabled=true;try{await downloadMyNFT(n)}catch(e){status(e.message||'Download fehlgeschlagen')}finally{b.disabled=false}})}catch(e){host.innerHTML=`<span class="identity-nft-empty">${esc(e.message||'NFTs konnten nicht geladen werden')}</span>`}}
async function toggleOwnedNFTs(host){if(!host)return;if(!host.classList.contains('hidden')){host.classList.add('hidden');return}host.classList.remove('hidden');await renderOwnedNFTs(host)}
async function performIdentityExport(){const p=prompt('Passphrase für den verschlüsselten Identitäts-Export (mindestens 12 Zeichen). Bewahre Export und Passphrase getrennt auf.');if(!p)return;try{const text=await exportIdentity(p),a=document.createElement('a');a.href=URL.createObjectURL(new Blob([text],{type:'application/json'}));a.download=`neuralhunt-identity-${cid.slice(0,10)}.json`;a.click();setTimeout(()=>URL.revokeObjectURL(a.href),5000);status('Identität verschlüsselt gesichert')}catch(e){status(e.message||'Export fehlgeschlagen')}}
async function performHostedLinkCode(){try{const x=await api('/api/me/customer-link',{method:'POST',body:JSON.stringify({})});const code=x.code||'';if(!code)throw new Error('Kein Hosted-Code erhalten');try{await navigator.clipboard?.writeText(code)}catch{}prompt('Einmaliger Hosted-Code (10 Minuten gültig). Im Customer-Service-Portal unter Haupt-Identität einfügen:',code);status('Hosted-Code erzeugt · nur einmal verwendbar')}catch(e){status(e.message||'Hosted-Code konnte nicht erzeugt werden')}}
async function performIdentityImport(input){const f=input?.files?.[0];if(!f)return;const p=prompt('Passphrase für diesen Identitäts-Export');if(!p){input.value='';return}try{const imported=await importIdentity(await f.text(),p),current=cid;if(imported.clientId===current){status('Diese Identität ist bereits aktiv');input.value='';return}if(!confirm(`Identität wechseln?\n\nAktuell: ${current}\nImport: ${imported.clientId}\n\nDie lokale Browser-Identität wird ersetzt. Sichere die aktuelle Identität vorher, wenn du sie später noch brauchst.`)){input.value='';return}localStorage.setItem(identityKey,JSON.stringify(imported.bundle));clearToken();location.reload()}catch(err){status(err.message||'Import fehlgeschlagen');input.value=''}}
$('mynfts').onclick=()=>toggleOwnedNFTs($('myNftsPanel'));
$('landingMyNfts').onclick=()=>toggleOwnedNFTs($('landingNftsPanel'));
$('hostedCode').onclick=performHostedLinkCode;$('landingHostedCode').onclick=performHostedLinkCode;
$('exportid').onclick=performIdentityExport;$('landingExportId').onclick=performIdentityExport;
$('importid').onchange=e=>performIdentityImport(e.target);$('landingImportId').onchange=e=>performIdentityImport(e.target);
addEventListener('beforeunload',()=>{stopTimers();clearWSReconnect();if(landingTimer)clearTimeout(landingTimer);if(ws){ws._plannedClose=true;ws.close()}window.removeEventListener('neuralhunt-mobile-mode',syncMobile);map.destroy()},{once:true});
}
@@ -429,8 +451,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','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)',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'];
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 msg=s=>$('adminstatus').textContent=s;
const saveDraft=()=>{try{draft.tab=tab;draft.selectedTaskId=selected?.id||draft.selectedTaskId||'';draft.filters={status:$('statusfilter')?.value||'',q:$('taskquery')?.value||''};localStorage.setItem(adminDraftKey,JSON.stringify(draft))}catch{}};
const draftScope=()=>tab==='task'&&selected?`task:${selected.id}`:`global:${tab}`;
@@ -443,7 +465,7 @@ async function runAdmin(){
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.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>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>
$('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>
<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>
+10
View File
@@ -104,3 +104,13 @@ html.mobile-mode .reference-admin-box,html.mobile-mode .task-style-admin{grid-te
/* Artifact worker diagnostics (v3.6). */
.artifact-state-wrap{gap:2px}.artifact-state{font-size:8px;text-transform:uppercase;letter-spacing:.06em}.artifact-state.ready{color:var(--green)}.artifact-state.generating,.artifact-state.pending{color:var(--amber)}.artifact-state.error{color:#ff9bad}.artifact-error-summary{display:block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.artifact-diagnostic{margin:10px 0 12px;border:1px solid rgba(255,95,136,.32);background:rgba(255,95,136,.055);border-radius:11px;padding:10px;display:grid;gap:7px}.artifact-diagnostic>div{display:grid;gap:5px}.artifact-diagnostic b{color:#ff9bad;font-size:9px;letter-spacing:.08em}.artifact-diagnostic span{color:#ffd3dc;font-size:8px;line-height:1.55;overflow-wrap:anywhere}.artifact-diagnostic small{color:#9c7580;font-size:7px;line-height:1.5}
.identity-actions{grid-template-columns:repeat(3,minmax(0,1fr))}.identity-actions button,.identity-actions .button{font-size:8px;padding:7px 5px}.identity-nfts{margin-top:8px;max-height:180px;overflow:auto;border:1px solid rgba(82,231,255,.12);border-radius:9px;background:rgba(2,8,16,.55)}.identity-nft-row{display:grid;grid-template-columns:1fr auto;gap:7px;align-items:center;padding:7px 8px;border-bottom:1px solid rgba(133,200,255,.08)}.identity-nft-row:last-child{border-bottom:0}.identity-nft-row b{display:block;font-size:8px;color:#dff8ff}.identity-nft-row small{display:block;margin-top:2px;font-size:7px;color:#68899d}.identity-nft-row button{font-size:7px;padding:6px}.identity-nft-empty{display:block;padding:8px;font-size:8px;color:#7f9bae;line-height:1.4}
.landing-identity-actions{display:grid;grid-template-columns:repeat(3,1fr);gap:6px}.landing-identity-actions>*{text-align:center;font-size:8px;padding:7px 5px}.landing-owned-nfts{max-height:210px;overflow:auto;border:1px solid rgba(82,231,255,.12);border-radius:9px;background:rgba(2,8,16,.55)}
/* Optional externally-auditable Beacon Hunt path choice. */
.beacon-choice{margin:12px 0;padding:12px;border:1px solid rgba(255,255,255,.12);border-radius:14px;background:rgba(3,9,19,.44)}
.beacon-choice.hidden{display:none}
.beacon-buttons{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin:8px 0}
.beacon-buttons button{min-width:0;padding:8px 5px;font-size:11px;letter-spacing:1px}
.beacon-buttons button.active{border-color:#fff;box-shadow:0 0 18px rgba(255,255,255,.14);background:rgba(255,255,255,.13)}
.beacon-choice>small{display:block;opacity:.68;line-height:1.35}