RC-10-B
All checks were successful
release-tag / release-image (push) Successful in 4m42s

This commit is contained in:
2026-08-11 22:46:15 +02:00
parent a88e75a8c4
commit d76f1c3d25
6 changed files with 204 additions and 23 deletions

View File

@@ -1,3 +1,5 @@
> **V4.2.7 Admin Live Guess Flash:** In der privaten Server-Admin-3D-Map kann `TIPPS %` optional den Score jedes tatsächlich ausgewerteten Tipps kurz am Client-Knoten einblenden auch wenn er unterhalb des bisherigen Highscores liegt. Die flüchtigen Werte laufen ausschließlich über einen admin-authentifizierten WebSocket und werden nicht an Spieler oder das öffentliche Leaderboard gesendet. Details: `V4.2.7_ADMIN_GUESS_FLASH.md`.
> **V4.2.6.1 Build Fix:** Das erste V4.2.6-Release-Archiv enthielt versehentlich nicht `internal/data/`. Dadurch konnte neuer Server-Code mit einem alten Data-Package kombiniert werden und der Server-Build scheiterte mit fehlenden `HostedCreditEvent`/Outbox-Methoden. Dieses Archiv enthält den vollständigen Data-Layer. Details: `V4.2.6.1_BUILD_FIX.md`.
> **V4.2.6 Customer Engagement + Admin Controls:** Der Hosted Customer Service kann neuen Konten ein konfigurierbares Startguthaben geben und Hosted Workern für echte persönliche Best-Score-Verbesserungen konfigurierbare Bonus-Credits gutschreiben. Das Customer Portal blendet PayPal vollständig aus, wenn PayPal deaktiviert ist, und fasst den Credit-Verlauf kompakt nach Tag/Buchungsart zusammen. Im privaten Customer-Admin gibt es Benutzer sperren/freigeben, Worker-Stop, Login-/Registrierungs-Kill-Switches, Registrierungs-Proof-of-Work und optionale einmalige Invite-Codes. Positive-Tip-Rewards werden über eine persistente Game-Outbox idempotent an den Customer Service zugestellt. Details: `V4.2.6_CUSTOMER_ENGAGEMENT_ADMIN.md`.

View File

@@ -0,0 +1,15 @@
# V4.2.7 Admin Live Guess Flash
Die 3D-Ansicht im privaten Server-Admin kann jetzt optional den Score jedes tatsächlich ausgewerteten Tipps kurz direkt am zugehörigen Client-Knoten einblenden.
- Neuer Schalter **TIPPS %** in der 3D-Map.
- Standardmäßig aus; der Zustand wird nur lokal im Browser gespeichert.
- Auch Scores unterhalb des bisherigen persönlichen Highscores werden für ca. 1,25 Sekunden angezeigt.
- Der Highscore-Knoten selbst bleibt bei einem schlechteren Tipp unverändert. Nur der flüchtige Prozentwert erscheint.
- Verbesserungen nutzen weiterhin die bestehende Bewegung/Signal-Animation und bekommen zusätzlich den Prozent-Flash.
- Bei aktivierter Tipp-Lotterie werden nur gezogene und damit tatsächlich ausgewertete Tipps angezeigt. Nicht gezogene Lose werden weiterhin nicht gegen das Ziel ausgewertet und besitzen daher keinen Score-Flash.
- Die Live-Daten laufen über den neuen, mit der Admin-Session geschützten WebSocket `/api/admin/ws`.
- Der WebSocket wird nur geöffnet, solange **TIPPS %** aktiviert ist.
- Per-Guess-Scores werden ausschließlich an authentifizierte Admin-WebSockets gesendet und niemals an Spieler- oder Leaderboard-WebSockets.
Für dieses Update muss nur das Server-Image neu gebaut werden. Customer-Service und Worker bleiben unverändert.

View File

@@ -38,22 +38,22 @@ import (
)
type Server struct {
store *data.Store
auth *auth.Manager
settings *settings.Manager
hub *wsx.Hub
runtime *rtx.State
artifactWorker *artifact.Worker
lottery *guessLottery
adminUser, adminPass, staticDir, artifactDir string
internalServiceSecret string
customerServiceInternalURL string
customerServiceHTTP *http.Client
upgrader websocket.Upgrader
wsAllowedOrigins map[string]struct{}
maxUserWS, maxLeaderboardWS int64
userWSCount, leaderboardWSCount atomic.Int64
adminSessions sync.Map // sid -> exp unix seconds
store *data.Store
auth *auth.Manager
settings *settings.Manager
hub *wsx.Hub
runtime *rtx.State
artifactWorker *artifact.Worker
lottery *guessLottery
adminUser, adminPass, staticDir, artifactDir string
internalServiceSecret string
customerServiceInternalURL string
customerServiceHTTP *http.Client
upgrader websocket.Upgrader
wsAllowedOrigins map[string]struct{}
maxUserWS, maxLeaderboardWS int64
userWSCount, leaderboardWSCount, adminWSCount atomic.Int64
adminSessions sync.Map // sid -> exp unix seconds
}
func New(store *data.Store, a *auth.Manager, sm *settings.Manager, hub *wsx.Hub, runtimeState *rtx.State, artifactDir string, artifactWorker *artifact.Worker) *Server {
@@ -331,6 +331,7 @@ func (s *Server) Routes() http.Handler {
r.Group(func(r chi.Router) {
r.Use(func(n http.Handler) http.Handler { return s.require("admin", n) })
r.Get("/api/admin/session", s.adminSession)
r.Get("/api/admin/ws", s.adminWS)
r.Get("/api/admin/overview", s.adminOverview)
r.Get("/api/admin/performance", s.adminPerformance)
r.Get("/api/admin/profiles/cleanup-preview", s.adminProfileCleanupPreview)
@@ -869,6 +870,18 @@ func (s *Server) guess(w http.ResponseWriter, r *http.Request) {
p.Score = round(p.Score, s.settings.Get().PublicScorePrecision)
s.hub.PublishPoint(id, c.ClientID, p)
}
// Admin-only live telemetry: expose the score of every actually evaluated
// guess, even when it is below the client's personal best. This event is
// ephemeral and never reaches public/user websocket clients.
_ = s.hub.PublishAdmin(r.Context(), wsx.Event{Type: "guess_signal", TaskID: id, Data: map[string]any{
"client_id": c.ClientID,
"seq": in.Seq,
"score": round(score, 2),
"best_score": round(accepted.State.BestScore, 2),
"improved": accepted.Improved,
"correct": correct,
}})
if correct {
rewardOwner := s.store.RewardOwnerForWorker(r.Context(), c.ClientID)
dataOut := map[string]string{"winner_client_id": rewardOwner, "winner_worker_client_id": c.ClientID}
@@ -1190,6 +1203,51 @@ func (s *Server) publicArtifactPreview(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(preview)
}
func (s *Server) adminWS(w http.ResponseWriter, r *http.Request) {
// The route itself is behind require("admin"), so the HttpOnly admin session
// cookie is validated before the websocket upgrade. Keep a small independent
// cap because this stream can contain one event for every evaluated guess.
if !acquireWSCap(&s.adminWSCount, 32) {
http.Error(w, "admin websocket capacity reached", http.StatusServiceUnavailable)
return
}
defer s.adminWSCount.Add(-1)
conn, err := s.upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
cl := wsx.NewAdminClient(conn)
s.hub.Add(cl)
defer s.hub.Remove(cl)
conn.SetReadLimit(1024)
_ = conn.SetReadDeadline(time.Now().Add(90 * time.Second))
conn.SetPongHandler(func(string) error { return conn.SetReadDeadline(time.Now().Add(90 * time.Second)) })
ping := time.NewTicker(30 * time.Second)
defer ping.Stop()
done := make(chan struct{})
go func() {
defer close(done)
for {
if _, _, err := conn.ReadMessage(); err != nil {
return
}
}
}()
for {
select {
case <-done:
return
case <-r.Context().Done():
return
case <-ping.C:
if err := cl.Ping(); err != nil {
return
}
}
}
}
func (s *Server) leaderboardWS(w http.ResponseWriter, r *http.Request) {
if !acquireWSCap(&s.leaderboardWSCount, s.maxLeaderboardWS) {
http.Error(w, "leaderboard websocket capacity reached", http.StatusServiceUnavailable)

View File

@@ -137,7 +137,7 @@ class NeuralMap {
this.drag=false; this.moved=false; this.lastX=0; this.lastY=0; this.mouseX=0; this.mouseY=0; this.hover=null;
this.width=1; this.height=1; this.dpr=1; this.last=performance.now(); this.lastPaint=0; this.fps=0; this.fpsFrames=0; this.fpsStart=performance.now();
this.renderCount=0; this.background=document.createElement('canvas'); this.backgroundKey='';
this.motion=new Map(); this.scoreMotion=new Map(); this.particles=[]; this.projected=[]; this.statsAt=0;
this.motion=new Map(); this.scoreMotion=new Map(); this.particles=[]; this.guessFlashes=[]; this.guessFlashEnabled=false; this.rawBy=new Map(); this.projected=[]; this.statsAt=0;
this.ro=new ResizeObserver(()=>this.resize()); this.ro.observe(host); this.resize(); this.bind();
this.frame=requestAnimationFrame(t=>this.draw(t));
}
@@ -175,7 +175,7 @@ class NeuralMap {
}
update(points,selfId,maxNodes){
const oldRaw=new Map(this.rawPoints.map(p=>[p.client_id,p])),now=performance.now();
this.rawPoints=Array.isArray(points)?points:[]; this.selfId=selfId||''; this.maxNodes=Math.max(10,Number(maxNodes||this.maxNodes));
this.rawPoints=Array.isArray(points)?points:[]; this.rawBy=new Map(this.rawPoints.map(p=>[p.client_id,p])); this.selfId=selfId||''; this.maxNodes=Math.max(10,Number(maxNodes||this.maxNodes));
for(const p of this.rawPoints){const prev=oldRaw.get(p.client_id);if(prev&&Number(p.score||0)>Number(prev.score||0)+.0001){this.scoreMotion.set(p.client_id,{from:Number(prev.score||0),to:Number(p.score||0),start:now,duration:900});this.emitSignal(p)}}
this.rebuild();
}
@@ -189,6 +189,13 @@ class NeuralMap {
const now=performance.now(),col=scoreRGB(p.score);
for(let i=0;i<3;i++)this.particles.push({id:p.client_id,start:now+i*95,duration:720+i*90,color:col,size:1+clamp(Number(p.score||0)/100,0,1)*.55});
}
flashGuess(data){
if(!this.guessFlashEnabled||!data)return;
const id=String(data.client_id||''),score=Number(data.score);
if(!id||!Number.isFinite(score))return;
if(this.guessFlashes.length>140)this.guessFlashes.splice(0,this.guessFlashes.length-100);
this.guessFlashes.push({id,score,best:Number(data.best_score||0),improved:!!data.improved,correct:!!data.correct,start:performance.now(),duration:data.correct?1800:1250});
}
rawCamera(){const panel=this.width>1050?this.opts.panelOffset:0;return {cy:Math.cos(this.yaw),sy:Math.sin(this.yaw),cp:Math.cos(this.pitch),sp:Math.sin(this.pitch),cx:this.width/2+panel,cyy:this.height/2,scale:Math.min(this.width*(this.opts.admin?.44:.40),this.height*.48)*this.zoom}}
projectRawXYZ(x,y,z){const cam=this.rawCamera();const x1=x*cam.cy-z*cam.sy,z1=x*cam.sy+z*cam.cy,y1=y*cam.cp-z1*cam.sp,z2=y*cam.sp+z1*cam.cp;const perspective=2.9/(3.3-z2*.042);return {x:cam.cx+x1*cam.scale*perspective/13.5,y:cam.cyy-y1*cam.scale*perspective/13.5,z:z2,p:perspective}}
projectXYZ(x,y,z){return this.projectRawXYZ(x,y,z)}
@@ -261,6 +268,13 @@ class NeuralMap {
for(let i=this.particles.length-1;i>=0;i--){const x=this.particles[i],t=(now-x.start)/x.duration;if(t<0)continue;if(t>=1){this.particles.splice(i,1);continue}const px=by.get(x.id);if(!px)continue;const q=px.q,ctrl=this.pathControl(q,core,x.id),e=smooth(t),u=1-e,bx=u*u*q.x+2*u*e*ctrl.x+e*e*core.x,byy=u*u*q.y+2*u*e*ctrl.y+e*e*core.y,r=(this.eco?2.0:5.0)*x.size*(.8+Math.sin(t*Math.PI)*.35);if(this.eco){c.fillStyle=rgba(x.color,.68);c.beginPath();c.arc(bx,byy,Math.max(1,r),0,Math.PI*2);c.fill()}else{const grad=c.createRadialGradient(bx,byy,0,bx,byy,r);grad.addColorStop(0,'rgba(255,255,255,.98)');grad.addColorStop(.2,rgba(x.color,.85));grad.addColorStop(1,rgba(x.color,0));c.fillStyle=grad;c.beginPath();c.arc(bx,byy,r,0,Math.PI*2);c.fill()}}
c.restore();
}
drawGuessFlashes(now){
if(!this.guessFlashEnabled||!this.guessFlashes.length)return;
const c=this.ctx,projectedBy=new Map(this.projected.filter(x=>!x.p._group).map(x=>[x.p.client_id,x.q]));
c.save();c.globalCompositeOperation='source-over';c.textBaseline='middle';c.textAlign='left';c.font=this.eco?'800 9px Inter,system-ui':'800 11px Inter,system-ui';
for(let i=this.guessFlashes.length-1;i>=0;i--){const f=this.guessFlashes[i],t=(now-f.start)/f.duration;if(t>=1){this.guessFlashes.splice(i,1);continue}if(t<0)continue;let q=projectedBy.get(f.id);if(!q){const raw=this.rawBy.get(f.id);if(raw)q=this.projectPoint(raw,now)}if(!q)continue;const rise=10+24*smooth(t),alpha=Math.pow(1-t,.72),col=f.correct?[255,255,255]:scoreRGB(f.score),text=`${f.score.toFixed(2)}%`,w=c.measureText(text).width+14,h=this.eco?17:20,x=clamp(q.x+9,4,this.width-w-4),y=clamp(q.y-rise,4,this.height-h-4);c.fillStyle=`rgba(2,7,14,${(.84*alpha).toFixed(3)})`;c.fillRect(x,y,w,h);c.strokeStyle=rgba(col,(f.improved?.72:.38)*alpha);c.lineWidth=f.correct?1.2:.7;c.strokeRect(x,y,w,h);c.fillStyle=rgba(col,.98*alpha);c.fillText(text,x+7,y+h/2+.2)}
c.restore();
}
drawNodes(projected,now){
const c=this.ctx,sorted=[...projected].filter(x=>!x.p._group).sort((a,b)=>Number((b.q.score??b.p.score)||0)-Number((a.q.score??a.p.score)||0)),topIDs=new Set(sorted.slice(0,10).map(x=>x.p.client_id));
c.save();c.globalCompositeOperation='lighter';
@@ -278,7 +292,7 @@ class NeuralMap {
pick(){if(!this.projected.length)return;let best=null,bestD=20;for(const x of this.projected){const d=Math.hypot(x.q.x-this.mouseX,x.q.y-this.mouseY);if(d<bestD){best=x;bestD=d}}this.hover=best;if(!best){this.tooltip.classList.add('hidden');return}const p=best.p,group=p._group||p._count>1,score=Number((best.q.score??p.score)||0),zone=score>=99?'99+ · unmittelbar am Task':score>=95?'9599 · sehr nah':score>=90?'9095 · nah':score>=75?'7590 · gutes Feld':score>=50?'5075 · mittlere Distanz':'<50 · weit';this.tooltip.innerHTML=group?`<strong>LOD-Gruppe · ${p._count} Clients</strong><small>Ø Score ${Number(p.score||0).toFixed(2)} · Best ${Number(p._bestScore||0).toFixed(2)}<br>${Number(p.guess_count||0).toLocaleString('de-DE')} Tipps</small>`:`<strong>${p.client_id===this.selfId?'DU':esc(String(p.client_id).slice(0,16))}</strong><small>Score ${score.toFixed(2)} · Rank #${p.rank||'—'}<br>${zone}<br>${Number(p.guess_count||0).toLocaleString('de-DE')} Tipps</small>`;this.tooltip.classList.remove('hidden');this.tooltip.style.left=clamp(this.mouseX+14,8,this.width-270)+'px';this.tooltip.style.top=clamp(this.mouseY+14,8,this.height-100)+'px'}
draw(now){const minFrame=this.eco?33:0;if(minFrame&&this.lastPaint&&now-this.lastPaint<minFrame){this.frame=requestAnimationFrame(t=>this.draw(t));return}this.lastPaint=now;const dt=Math.min(.075,(now-this.last)/1000);this.last=now;if(this.autoRotate&&!this.drag)this.yaw+=dt*.035;this.fpsFrames++;if(now-this.fpsStart>800){this.fps=Math.round(this.fpsFrames*1000/(now-this.fpsStart));this.fpsFrames=0;this.fpsStart=now}
const c=this.ctx;c.setTransform(this.dpr,0,0,this.dpr,0,0);this.drawBackground(now);this.drawAura(now);this.drawTargetField();this.drawRawShells();this.drawClouds(now);
const projected=[];for(const p of this.points)projected.push({p,q:this.projectPoint(p,now)});projected.sort((a,b)=>a.q.z-b.q.z);this.projected=projected;this.renderCount=projected.length;this.drawEdges(projected);this.drawParticles(now);this.drawNodes(projected,now);this.drawCore(now);if(this.hover)this.pick();
const projected=[];for(const p of this.points)projected.push({p,q:this.projectPoint(p,now)});projected.sort((a,b)=>a.q.z-b.q.z);this.projected=projected;this.renderCount=projected.length;this.drawEdges(projected);this.drawParticles(now);this.drawNodes(projected,now);this.drawGuessFlashes(now);this.drawCore(now);if(this.hover)this.pick();
if(this.opts.onStats&&now>this.statsAt){this.statsAt=now+500;this.opts.onStats({fps:this.fps,render:this.renderCount,raw:this.rawPoints.length,groups:this.points.filter(p=>p._group).length})}
this.frame=requestAnimationFrame(t=>this.draw(t));
}
@@ -440,20 +454,24 @@ function adminShell(){
<nav class="admin-mobile-tabs glass" id="adminMobileTabs"><button data-admin-panel="map" class="active">MAP</button><button data-admin-panel="tasks">TASKS</button><button data-admin-panel="control">CONTROL</button></nav>
<main class="adminGrid mobile-show-map" id="adminGrid">
<section class="glass tasklist"><div class="panel-title"><span>TASKS</span><span class="chip" id="taskCount">0</span></div><div class="toolbar"><select id="statusfilter"><option value="">alle Status</option><option>active</option><option>completed</option><option>closed</option></select><input id="taskquery" placeholder="Task / Winner"><button id="filter">FILTER</button></div><div class="tasks" id="tasks"></div></section>
<section class="adminMap glass"><div id="adminmap" class="neural-map"></div><div class="map-overlay top"><span><b id="selectedtask">Task wählen</b></span><span><b id="adminbits">—</b> Bit</span><span><b id="adminpoints">0</b> Clients</span><span><b id="adminrender">0</b> Render</span><span><b id="adminfps">0</b> FPS</span><span id="winner"></span><label class="inline-filter">Score ≥ <input id="adminMinScore" type="number" min="0" max="100" step="1" value="0"></label><input id="adminClientFilter" class="client-filter" placeholder="Client-ID filtern"></div><div class="map-overlay bottom"><label>MAX NODES <input id="adminmaxnodes" type="range" min="100" max="50000" step="100" value="5000"><b id="adminmaxvalue">5.000</b></label><button id="adminProximity" class="active">TARGET FIELD</button><button id="adminRotate" class="active">ORBIT</button><button id="adminEdges" class="active">SIGNALWEGE</button><button id="adminShells" class="active">SCORE-RINGE</button><button id="adminLOD" class="active">LOD</button><button id="adminEco">ECO</button><button id="adminReset">RESET</button></div></section>
<section class="adminMap glass"><div id="adminmap" class="neural-map"></div><div class="map-overlay top"><span><b id="selectedtask">Task wählen</b></span><span><b id="adminbits">—</b> Bit</span><span><b id="adminpoints">0</b> Clients</span><span><b id="adminrender">0</b> Render</span><span><b id="adminfps">0</b> FPS</span><span id="winner"></span><label class="inline-filter">Score ≥ <input id="adminMinScore" type="number" min="0" max="100" step="1" value="0"></label><input id="adminClientFilter" class="client-filter" placeholder="Client-ID filtern"></div><div class="map-overlay bottom"><label>MAX NODES <input id="adminmaxnodes" type="range" min="100" max="50000" step="100" value="5000"><b id="adminmaxvalue">5.000</b></label><button id="adminProximity" class="active">TARGET FIELD</button><button id="adminRotate" class="active">ORBIT</button><button id="adminEdges" class="active">SIGNALWEGE</button><button id="adminShells" class="active">SCORE-RINGE</button><button id="adminLOD" class="active">LOD</button><button id="adminGuessFlash" title="Jeden ausgewerteten Tipp kurz als Prozentwert anzeigen, auch unterhalb des Highscores">TIPPS %</button><button id="adminEco">ECO</button><button id="adminReset">RESET</button></div></section>
<section class="glass settings"><div class="panel-title"><span>CONTROL PLANE</span><span class="chip">SQLite</span></div><div class="settings-tabs"><button id="tabRuntime" class="active">RUNTIME</button><button id="tabTask">TASK ACTIONS</button><button id="tabArtifact">ARTIFACT</button></div><div id="settingfields"></div><div class="actions"><button id="savesettings">SPEICHERN</button><button id="ensuretasks">ACTIVE TASKS SICHERN</button></div><p class="small statusline" id="adminstatus"></p></section>
</main>`;
}
async function runAdmin(){
let adminOK=false;try{await api('/api/admin/session',{},true);adminOK=true}catch{}if(!adminOK){adminLoginShell();$('adminlogin').onclick=async()=>{try{const r=await fetch('/api/admin/login',{method:'POST',headers:{'Content-Type':'application/json'},credentials:'same-origin',body:JSON.stringify({user:$('adminuser').value,password:$('adminpass').value})});if(!r.ok)throw new Error('Login fehlgeschlagen');location.reload()}catch(e){$('adminerr').textContent=e.message}};return}
adminShell();const map=new NeuralMap($('adminmap'),{admin:true,onStats:s=>{if($('adminrender'))$('adminrender').textContent=s.render.toLocaleString('de-DE');if($('adminfps'))$('adminfps').textContent=s.fps}});const adminDraftKey='neuralhunt.adminDraft.v2';let draft={};try{draft=JSON.parse(localStorage.getItem(adminDraftKey)||'{}')||{}}catch{draft={}};let tasks=[],selected=null,points=[],settings=null,actions=[],providers=null,artifactUsage=null,poll=null,tab=['runtime','task','artifact'].includes(draft.tab)?draft.tab:'runtime',loading=false;
adminShell();const map=new NeuralMap($('adminmap'),{admin:true,onStats:s=>{if($('adminrender'))$('adminrender').textContent=s.render.toLocaleString('de-DE');if($('adminfps'))$('adminfps').textContent=s.fps}});const adminGuessFlashKey='neuralhunt.adminGuessFlash.v1';let adminGuessFlash=false;try{adminGuessFlash=localStorage.getItem(adminGuessFlashKey)==='1'}catch{}map.guessFlashEnabled=adminGuessFlash;setActive('adminGuessFlash',adminGuessFlash);const adminDraftKey='neuralhunt.adminDraft.v2';let draft={};try{draft=JSON.parse(localStorage.getItem(adminDraftKey)||'{}')||{}}catch{draft={}};let tasks=[],selected=null,points=[],settings=null,actions=[],providers=null,artifactUsage=null,poll=null,tab=['runtime','task','artifact'].includes(draft.tab)?draft.tab:'runtime',loading=false;
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 (110)',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;
let adminSignalWS=null,adminSignalTimer=null,adminSignalBackoff=500,adminSignalClosed=false;
const closeAdminSignalWS=()=>{clearTimeout(adminSignalTimer);adminSignalTimer=null;if(adminSignalWS){adminSignalWS._plannedClose=true;try{adminSignalWS.close(1000,'disabled')}catch{}adminSignalWS=null}};
const openAdminSignalWS=()=>{if(!adminGuessFlash||adminSignalClosed)return;if(adminSignalWS&&(adminSignalWS.readyState===WebSocket.OPEN||adminSignalWS.readyState===WebSocket.CONNECTING))return;const proto=location.protocol==='https:'?'wss':'ws',socket=new WebSocket(`${proto}://${location.host}/api/admin/ws`);adminSignalWS=socket;socket.onopen=()=>{if(adminSignalWS!==socket)return;adminSignalBackoff=500};socket.onmessage=ev=>{if(adminSignalWS!==socket||!adminGuessFlash)return;try{const e=JSON.parse(ev.data);if(e.type==='guess_signal'&&selected?.id===e.task_id)map.flashGuess(e.data)}catch{}};socket.onclose=()=>{if(adminSignalWS===socket)adminSignalWS=null;if(!socket._plannedClose&&adminGuessFlash&&!adminSignalClosed){clearTimeout(adminSignalTimer);adminSignalTimer=setTimeout(openAdminSignalWS,adminSignalBackoff);adminSignalBackoff=Math.min(10000,adminSignalBackoff*1.8)}}};
if(adminGuessFlash)openAdminSignalWS();
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}`;
const draftFieldKey=el=>el.dataset.setting?`setting:${el.dataset.setting}`:el.dataset.settingString?`string:${el.dataset.settingString}`:el.dataset.draft||el.id||'';
@@ -547,12 +565,12 @@ async function runAdmin(){
async function load(keepMessage=false,refreshControls=false){if(loading)return;loading=true;try{const status=$('statusfilter').value,q=$('taskquery').value,dayStart=new Date();dayStart.setHours(0,0,0,0);const [ts,st,ov,pv,pf,au]=await Promise.all([api(`/api/admin/tasks?status=${encodeURIComponent(status)}&q=${encodeURIComponent(q)}&limit=300`,{},true),api('/api/admin/settings',{},true),api('/api/admin/overview',{},true),api('/api/admin/artifact/providers',{},true),api('/api/admin/performance',{},true),api(`/api/admin/artifact/usage?day_start_ms=${dayStart.getTime()}`,{},true)]);tasks=Array.isArray(ts)?ts:[];settings=st||{};providers=pv||{};artifactUsage=au||{};refreshArtifactUsageTelemetry();renderOverview(ov||{});renderPerformance(pf||{});if(selected){selected=tasks.find(t=>t.id===selected.id)||selected}renderTasks();if(selected)await refreshSelected(refreshControls);else if(refreshControls)renderSettings()}catch(e){if(e.status===401){location.reload();return}msg(e.message||'Laden fehlgeschlagen')}finally{loading=false}}
async function openTask(t){captureDraft();selected=t;draft.selectedTaskId=t?.id||'';saveDraft();await refreshSelected(true);renderTasks();if(document.documentElement.classList.contains('mobile-mode'))setAdminPanel('map')}
$('filter').onclick=()=>{saveDraft();load()};$('statusfilter').onchange=()=>{saveDraft();load()};$('taskquery').addEventListener('input',saveDraft);$('taskquery').addEventListener('keydown',e=>{if(e.key==='Enter'){saveDraft();load()}});$('adminmaxnodes').oninput=e=>{$('adminmaxvalue').textContent=Number(e.target.value).toLocaleString('de-DE');renderMap()};$('adminMinScore').oninput=renderMap;$('adminClientFilter').oninput=renderMap;
$('adminProximity').onclick=()=>{map.proximityFocus=!map.proximityFocus;$('adminProximity').textContent=map.proximityFocus?'TARGET FIELD':'RAW 3D';setActive('adminProximity',map.proximityFocus)};$('adminRotate').onclick=()=>{map.autoRotate=!map.autoRotate;setActive('adminRotate',map.autoRotate)};$('adminEdges').onclick=()=>{map.edges=!map.edges;setActive('adminEdges',map.edges)};$('adminShells').onclick=()=>{map.shells=!map.shells;setActive('adminShells',map.shells)};$('adminLOD').onclick=()=>{map.lodEnabled=!map.lodEnabled;map.rebuild();setActive('adminLOD',map.lodEnabled)};$('adminEco').onclick=()=>{map.eco=!map.eco;map.resize();setActive('adminEco',map.eco)};$('adminReset').onclick=()=>map.resetView();
$('adminProximity').onclick=()=>{map.proximityFocus=!map.proximityFocus;$('adminProximity').textContent=map.proximityFocus?'TARGET FIELD':'RAW 3D';setActive('adminProximity',map.proximityFocus)};$('adminRotate').onclick=()=>{map.autoRotate=!map.autoRotate;setActive('adminRotate',map.autoRotate)};$('adminEdges').onclick=()=>{map.edges=!map.edges;setActive('adminEdges',map.edges)};$('adminShells').onclick=()=>{map.shells=!map.shells;setActive('adminShells',map.shells)};$('adminLOD').onclick=()=>{map.lodEnabled=!map.lodEnabled;map.rebuild();setActive('adminLOD',map.lodEnabled)};$('adminGuessFlash').onclick=()=>{adminGuessFlash=!adminGuessFlash;map.guessFlashEnabled=adminGuessFlash;if(!adminGuessFlash)map.guessFlashes=[];setActive('adminGuessFlash',adminGuessFlash);try{localStorage.setItem(adminGuessFlashKey,adminGuessFlash?'1':'0')}catch{}if(adminGuessFlash)openAdminSignalWS();else closeAdminSignalWS()};$('adminEco').onclick=()=>{map.eco=!map.eco;map.resize();setActive('adminEco',map.eco)};$('adminReset').onclick=()=>map.resetView();
$('tabRuntime').onclick=()=>{captureDraft();tab='runtime';saveDraft();renderSettings()};$('tabTask').onclick=()=>{captureDraft();tab='task';saveDraft();renderSettings()};$('tabArtifact').onclick=()=>{captureDraft();tab='artifact';saveDraft();renderSettings()};
$('savesettings').onclick=async()=>{try{captureDraft();const out={...settings};document.querySelectorAll('[data-setting]').forEach(i=>out[i.dataset.setting]=Number(i.value));document.querySelectorAll('[data-setting-string]').forEach(i=>out[i.dataset.settingString]=i.value);settings=await api('/api/admin/settings',{method:'PUT',body:JSON.stringify(out)},true);if(draft.fields)delete draft.fields[draftScope()];saveDraft();msg('gespeichert');renderSettings()}catch(e){msg(e.message)}};
$('ensuretasks').onclick=async()=>{try{await api('/api/admin/tasks/ensure',{method:'POST'},true);msg('aktive Tasks sichergestellt');await load(true,true)}catch(e){msg(e.message)}};
$('adminlogout').onclick=async()=>{try{await fetch('/api/admin/logout',{method:'POST',credentials:'same-origin'})}finally{location.reload()}};
$('settingfields').addEventListener('input',captureDraft);$('settingfields').addEventListener('change',captureDraft);if(draft.filters){$('statusfilter').value=draft.filters.status||'';$('taskquery').value=draft.filters.q||''}await load();const first=tasks.find(t=>t.id===draft.selectedTaskId)||(tasks.find(t=>t.status==='active')||tasks[0]);if(first)await openTask(first);else renderSettings();poll=setInterval(()=>load(true,false),3000);addEventListener('beforeunload',()=>{captureDraft();clearInterval(poll);window.removeEventListener('neuralhunt-mobile-mode',syncAdminMobile);map.destroy()},{once:true});
$('settingfields').addEventListener('input',captureDraft);$('settingfields').addEventListener('change',captureDraft);if(draft.filters){$('statusfilter').value=draft.filters.status||'';$('taskquery').value=draft.filters.q||''}await load();const first=tasks.find(t=>t.id===draft.selectedTaskId)||(tasks.find(t=>t.status==='active')||tasks[0]);if(first)await openTask(first);else renderSettings();poll=setInterval(()=>load(true,false),3000);addEventListener('beforeunload',()=>{adminSignalClosed=true;closeAdminSignalWS();captureDraft();clearInterval(poll);window.removeEventListener('neuralhunt-mobile-mode',syncAdminMobile);map.destroy()},{once:true});
}
applyMobileMode(mobileModeEnabled());

View File

@@ -21,6 +21,7 @@ type Client struct {
TaskID string
ClientID string
All bool
Admin bool
send chan []byte
closed chan struct{}
onWrite func(int)
@@ -32,6 +33,10 @@ type Client struct {
func NewClient(conn *websocket.Conn, taskID, clientID string, all bool) *Client {
return &Client{Conn: conn, TaskID: taskID, ClientID: clientID, All: all, send: make(chan []byte, 32), closed: make(chan struct{})}
}
func NewAdminClient(conn *websocket.Conn) *Client {
return &Client{Conn: conn, ClientID: "admin", All: true, Admin: true, send: make(chan []byte, 64), closed: make(chan struct{})}
}
func (c *Client) start() { go c.writeLoop() }
func (c *Client) writeLoop() {
for {
@@ -159,6 +164,47 @@ func (h *Hub) Publish(ctx context.Context, e Event) error {
return nil
}
// PublishAdmin sends an ephemeral event only to authenticated admin websocket
// clients. It is intentionally separate from the public/task broadcast path so
// per-guess telemetry can never leak to normal players or the leaderboard.
func (h *Hub) PublishAdmin(ctx context.Context, e Event) error {
if err := ctx.Err(); err != nil {
return err
}
h.mu.RLock()
hasAdmin := false
for c := range h.clients {
if c.Admin {
hasAdmin = true
break
}
}
h.mu.RUnlock()
if !hasAdmin {
return nil
}
b, err := json.Marshal(e)
if err != nil {
return err
}
h.broadcastAdminBytes(b)
return nil
}
func (h *Hub) broadcastAdminBytes(b []byte) {
h.mu.RLock()
clients := make([]*Client, 0, 4)
for c := range h.clients {
if c.Admin {
clients = append(clients, c)
}
}
h.mu.RUnlock()
for _, c := range clients {
c.EnqueueBytes(b)
}
}
// PublishPoint coalesces repeated improvements for the same client. Every 250ms
// all changed points for a task are emitted as one frame per websocket instead
// of one synchronous write per guess per client.

View File

@@ -0,0 +1,42 @@
package ws
import (
"context"
"encoding/json"
"testing"
"time"
)
func TestPublishAdminDoesNotReachPublicClients(t *testing.T) {
h := New()
admin := &Client{Admin: true, send: make(chan []byte, 1), closed: make(chan struct{})}
public := &Client{All: true, send: make(chan []byte, 1), closed: make(chan struct{})}
h.mu.Lock()
h.clients[admin] = struct{}{}
h.clients[public] = struct{}{}
h.mu.Unlock()
if err := h.PublishAdmin(context.Background(), Event{Type: "guess_signal", TaskID: "task-1", Data: map[string]any{"score": 42.5}}); err != nil {
t.Fatalf("PublishAdmin: %v", err)
}
select {
case b := <-admin.send:
var e Event
if err := json.Unmarshal(b, &e); err != nil {
t.Fatalf("decode admin event: %v", err)
}
if e.Type != "guess_signal" || e.TaskID != "task-1" {
t.Fatalf("unexpected event: %#v", e)
}
case <-time.After(100 * time.Millisecond):
t.Fatal("admin client did not receive event")
}
select {
case b := <-public.send:
t.Fatalf("public client received admin-only event: %s", b)
default:
}
}