diff --git a/README.md b/README.md index 43319ad..b2d91ea 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +> **V3.5 Random Guess Lottery:** Optional kann die Zahl der tatsächlich ausgewerteten Tipps pro Zeitfenster begrenzt werden. Im Admin-Tab **RUNTIME** steuern `Lotterie-Zeitfenster (s)` und `Max. gezogene Tipps je Task/Fenster` die Funktion; `0` deaktiviert sie vollständig. Die Lotterie läuft getrennt pro aktivem Task. Alle gültigen, signierten Tipps werden bis zum Ende des Zeitfensters gesammelt und anschließend mit `crypto/rand` gleichberechtigt zufällig gezogen. Nur gezogene Tipps werden gegen das geheime Ziel ausgewertet und können Score/Winner/NFT auslösen. Nicht gezogene Tipps verbrauchen ihre Sequenz, damit beim nächsten Fenster ein neuer deterministischer Tipp entsteht, zählen aber nicht als akzeptierter `guess_count`. Abgebrochene HTTP-Requests verbrauchen keinen Lotterie-Slot. Dadurch wird die mögliche Task-Abschluss-/NFT-Rate gedrosselt, ohne Gewinner oder Styles direkt zu manipulieren. + # Neural Hunt — V3.1 RIFT Task-Style Collection + Profile Cleanup @@ -20,6 +22,11 @@ Neural Hunt ist eine eigenständige Go-Webanwendung für das beschriebene sozial Die Weboberfläche liegt unter `internal/webui/dist/` und wird mit `//go:embed` in das Binary eingebettet. Der Server lädt beim lokalen Start automatisch eine vorhandene `.env` (bereits gesetzte Prozess-Umgebungsvariablen haben Vorrang), daher reicht `go run ./cmd/server` für Backend, Client-UI, Admin-UI und öffentliches Echtzeit-Leaderboard. +## HTTPS / Browser-WebCrypto + +Die Weboberfläche erzeugt ihre ECDSA-P-256-Identität direkt im Browser über die Web Crypto API. Browser stellen `crypto.subtle` für normale Remote-Hosts nur in einem sicheren Kontext bereit. Verwende die Weboberfläche deshalb produktiv über **HTTPS**. `http://localhost` bzw. Loopback-Adressen werden von Browsern für lokale Entwicklung üblicherweise als vertrauenswürdiger Kontext behandelt. Bei einem Aufruf über `http://` zeigt Neural Hunt eine verständliche HTTPS-Fehlermeldung statt eines JavaScript-Fehlers. + + ## Schnellstart ```bash diff --git a/TESTING.md b/TESTING.md index 6fabe60..f154e44 100644 --- a/TESTING.md +++ b/TESTING.md @@ -332,3 +332,12 @@ go test ./internal/webui ./internal/core ./internal/auth ./internal/artifact ./i - The API rejects cleanup windows shorter than one hour. Relevant automated tests: `internal/data/profile_cleanup_test.go` and `internal/server/profile_cleanup_test.go`. + +## V3.5 Random Guess Lottery + +1. Admin → RUNTIME: `Lotterie-Zeitfenster (s)=20`, `Max. gezogene Tipps je Task/Fenster=2` setzen und speichern. +2. Mindestens drei Clients mit demselben Task verbinden und innerhalb desselben Fensters je einen Tipp absenden lassen. +3. Bis zum Fensterende müssen die Requests auf die Losziehung warten. Danach dürfen höchstens zwei Clients `Tipp gezogen & geprüft` sehen; übrige Clients sehen `Tipp diesmal nicht gezogen`. +4. Admin-Telemetrie: `Reject/s` steigt für nicht gezogene Tipps; `Guess/s` zählt nur tatsächlich gezogene/ausgewertete Tipps. +5. Mit `Max. gezogene Tipps je Task/Fenster=0` speichern: Tipps müssen wieder ohne Lotterie-Verzögerung normal verarbeitet werden. +6. Bei mehreren aktiven Tasks prüfen, dass jeder Task sein eigenes Kontingent erhält. diff --git a/internal/runtime/state.go b/internal/runtime/state.go index 7bc3dc1..57febc0 100644 --- a/internal/runtime/state.go +++ b/internal/runtime/state.go @@ -176,6 +176,57 @@ type AcceptResult struct { Improved bool } +// CanSubmit validates sequence and per-client timing before a request is +// admitted into the global-per-task lottery. It does not mutate state, so an +// unselected ticket can be consumed explicitly by SkipLottery. +func (s *State) CanSubmit(task data.Task, cid string, seq int64, minInterval time.Duration) error { + k := guessKey{task.ID, cid} + now := time.Now().UTC() + s.mu.RLock() + g, ok := s.guesses[k] + s.mu.RUnlock() + if !ok || g.PublicSeed != task.PublicSeed || seq != g.NextSeq { + s.record(false, false, true) + return ErrBadSequence + } + if !g.LastGuess.IsZero() && minInterval > 0 && now.Sub(g.LastGuess) < minInterval { + s.record(false, false, true) + return ErrRateLimited + } + return nil +} + +// SkipLottery consumes a valid sequence that lost the random draw without +// evaluating/scoring it. This prevents the same deterministic guess from being +// resubmitted forever while keeping guess_count reserved for actually accepted +// and evaluated tips. +func (s *State) SkipLottery(task data.Task, cid string, seq int64, minInterval time.Duration) (int64, error) { + k := guessKey{task.ID, cid} + now := time.Now().UTC() + s.mu.Lock() + g, ok := s.guesses[k] + if !ok || g.PublicSeed != task.PublicSeed || seq != g.NextSeq { + next := g.NextSeq + s.mu.Unlock() + s.record(false, false, true) + return next, ErrBadSequence + } + if !g.LastGuess.IsZero() && minInterval > 0 && now.Sub(g.LastGuess) < minInterval { + next := g.NextSeq + s.mu.Unlock() + s.record(false, false, true) + return next, ErrRateLimited + } + g.NextSeq++ + g.LastGuess = now + g.Revision = task.Revision + s.guesses[k] = g + next := g.NextSeq + s.mu.Unlock() + s.record(false, false, true) + return next, nil +} + func (s *State) Accept(task data.Task, cid string, seq int64, score float64, minInterval time.Duration) (AcceptResult, error) { k := guessKey{task.ID, cid} now := time.Now().UTC() diff --git a/internal/runtime/state_test.go b/internal/runtime/state_test.go index 558d110..728f7d3 100644 --- a/internal/runtime/state_test.go +++ b/internal/runtime/state_test.go @@ -79,3 +79,26 @@ func TestGuessHotPathAndReroll(t *testing.T) { t.Fatalf("reroll not reset: %+v", g) } } + +func TestLotterySkipConsumesSequenceWithoutCountingAcceptedGuess(t *testing.T) { + s := New() + task := data.Task{ID: "lottery-task", PublicSeed: "seed", Revision: 1} + s.InitGuess(task, "client", GuessState{}) + if err := s.CanSubmit(task, "client", 0, 0); err != nil { + t.Fatalf("eligible guess rejected: %v", err) + } + next, err := s.SkipLottery(task, "client", 0, 0) + if err != nil { + t.Fatal(err) + } + if next != 1 { + t.Fatalf("next seq = %d, want 1", next) + } + g, ok := s.Current(task, "client") + if !ok { + t.Fatal("missing runtime state") + } + if g.NextSeq != 1 || g.GuessCount != 0 || g.LastGuess.IsZero() { + t.Fatalf("unexpected lottery skip state: %+v", g) + } +} diff --git a/internal/server/guess_lottery.go b/internal/server/guess_lottery.go new file mode 100644 index 0000000..d565b62 --- /dev/null +++ b/internal/server/guess_lottery.go @@ -0,0 +1,135 @@ +package server + +import ( + "context" + crand "crypto/rand" + "errors" + "math/big" + "sync" + "time" +) + +var ( + errLotteryDuplicate = errors.New("guess already entered in current lottery window") + errLotteryFull = errors.New("guess lottery window is full") +) + +const maxLotteryTicketsPerWindow = 250000 + +type lotteryTicket struct { + key string + ctx context.Context + result chan bool +} + +type lotteryBucket struct { + max int + end time.Time + tickets []*lotteryTicket + keys map[string]struct{} +} + +type guessLottery struct { + mu sync.Mutex + buckets map[string]*lotteryBucket +} + +func newGuessLottery() *guessLottery { + return &guessLottery{buckets: make(map[string]*lotteryBucket)} +} + +// enter batches all valid tips for a task into aligned time windows. At the +// window boundary exactly up to max tickets are selected uniformly at random. +// The request intentionally waits for the draw so later arrivals in the same +// window have the same chance as earlier arrivals. +func (l *guessLottery) enter(ctx context.Context, taskID, clientID string, seq int64, window time.Duration, max int) (bool, error) { + if max <= 0 || window <= 0 { + return true, nil + } + now := time.Now().UTC() + windowNS := window.Nanoseconds() + if windowNS <= 0 { + return true, nil + } + idx := now.UnixNano() / windowNS + end := time.Unix(0, (idx+1)*windowNS).UTC() + bucketKey := taskID + "|" + end.Format(time.RFC3339Nano) + "|" + window.String() + ticketKey := clientID + "|" + big.NewInt(seq).String() + t := &lotteryTicket{key: ticketKey, ctx: ctx, result: make(chan bool, 1)} + + l.mu.Lock() + b := l.buckets[bucketKey] + if b == nil { + b = &lotteryBucket{max: max, end: end, keys: make(map[string]struct{})} + l.buckets[bucketKey] = b + delay := time.Until(end) + if delay < 0 { + delay = 0 + } + time.AfterFunc(delay, func() { l.draw(bucketKey) }) + } + if _, exists := b.keys[ticketKey]; exists { + l.mu.Unlock() + return false, errLotteryDuplicate + } + if len(b.tickets) >= maxLotteryTicketsPerWindow { + l.mu.Unlock() + return false, errLotteryFull + } + b.keys[ticketKey] = struct{}{} + b.tickets = append(b.tickets, t) + l.mu.Unlock() + + select { + case selected := <-t.result: + return selected, nil + case <-ctx.Done(): + return false, ctx.Err() + } +} + +func (l *guessLottery) draw(bucketKey string) { + l.mu.Lock() + b := l.buckets[bucketKey] + if b == nil { + l.mu.Unlock() + return + } + delete(l.buckets, bucketKey) + tickets := append([]*lotteryTicket(nil), b.tickets...) + max := b.max + l.mu.Unlock() + + // Canceled HTTP requests do not consume one of the scarce winning slots. + alive := tickets[:0] + for _, t := range tickets { + select { + case <-t.ctx.Done(): + // skip + default: + alive = append(alive, t) + } + } + tickets = alive + if max > len(tickets) { + max = len(tickets) + } + // Partial Fisher-Yates with crypto/rand gives every ticket equal odds. + for i := 0; i < max; i++ { + nBig, err := crand.Int(crand.Reader, big.NewInt(int64(len(tickets)-i))) + if err != nil { + // crypto/rand failure is extremely unusual; deterministic fallback still + // keeps the quota safe, but does not claim cryptographic randomness. + nBig = big.NewInt(0) + } + j := i + int(nBig.Int64()) + tickets[i], tickets[j] = tickets[j], tickets[i] + } + for i, t := range tickets { + selected := i < max + select { + case t.result <- selected: + default: + } + } +} diff --git a/internal/server/guess_lottery_test.go b/internal/server/guess_lottery_test.go new file mode 100644 index 0000000..368b105 --- /dev/null +++ b/internal/server/guess_lottery_test.go @@ -0,0 +1,44 @@ +package server + +import ( + "context" + "fmt" + "testing" + "time" +) + +func TestGuessLotteryDrawSelectsExactQuota(t *testing.T) { + l := newGuessLottery() + const total = 40 + const quota = 9 + b := &lotteryBucket{max: quota, end: time.Now().Add(time.Second), keys: make(map[string]struct{})} + for i := 0; i < total; i++ { + ticket := &lotteryTicket{key: fmt.Sprintf("c-%d|0", i), ctx: context.Background(), result: make(chan bool, 1)} + b.tickets = append(b.tickets, ticket) + b.keys[ticket.key] = struct{}{} + } + l.buckets["test"] = b + l.draw("test") + selected := 0 + for _, ticket := range b.tickets { + if <-ticket.result { + selected++ + } + } + if selected != quota { + t.Fatalf("selected %d tickets, want %d", selected, quota) + } +} + +func TestGuessLotteryCanceledTicketDoesNotConsumeQuota(t *testing.T) { + l := newGuessLottery() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + canceled := &lotteryTicket{key: "canceled", ctx: ctx, result: make(chan bool, 1)} + alive := &lotteryTicket{key: "alive", ctx: context.Background(), result: make(chan bool, 1)} + l.buckets["test"] = &lotteryBucket{max: 1, tickets: []*lotteryTicket{canceled, alive}, keys: map[string]struct{}{"canceled": {}, "alive": {}}} + l.draw("test") + if got := <-alive.result; !got { + t.Fatal("live ticket should receive the available slot") + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 83d2de8..0275b5b 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -37,6 +37,7 @@ type Server struct { hub *wsx.Hub runtime *rtx.State artifactWorker *artifact.Worker + lottery *guessLottery adminUser, adminPass, staticDir, artifactDir string upgrader websocket.Upgrader } @@ -49,6 +50,7 @@ func New(store *data.Store, a *auth.Manager, sm *settings.Manager, hub *wsx.Hub, hub: hub, runtime: runtimeState, artifactWorker: artifactWorker, + lottery: newGuessLottery(), adminUser: env("ADMIN_USER", "admin"), adminPass: env("ADMIN_PASSWORD", "change-me"), staticDir: env("STATIC_DIR", ""), @@ -351,6 +353,8 @@ func taskDTO(t data.Task, next int64, sm settings.Runtime) map[string]any { "next_seq": next, "server_min_interval_sec": serverMin, "client_submit_interval_sec": clientSubmit, + "guess_lottery_window_sec": sm.GuessLotteryWindowSec, + "guess_lottery_max_accepted": sm.GuessLotteryMaxAccepted, "default_max_nodes": sm.DefaultMaxNodes, "paused": t.Paused, "revision": t.Revision, @@ -497,6 +501,78 @@ func (s *Server) guess(w http.ResponseWriter, r *http.Request) { jsonOut(w, 401, false) return } + if _, ok := s.runtime.Current(t.Task, c.ClientID); !ok { + snap, _ := s.store.LoadGuessState(r.Context(), t.ID, c.ClientID) + s.runtime.InitGuess(t.Task, c.ClientID, rtx.GuessState{NextSeq: snap.NextSeq, LastGuess: snap.LastGuess, BestScore: snap.BestScore, GuessCount: snap.GuessCount}) + } + cfg := s.settings.Get() + serverMin, _ := taskIntervals(t.Task, cfg) + minInterval := time.Duration(serverMin) * time.Second + if err := s.runtime.CanSubmit(t.Task, c.ClientID, in.Seq, minInterval); err != nil { + switch { + case errors.Is(err, rtx.ErrRateLimited): + jsonOut(w, 429, false) + case errors.Is(err, rtx.ErrBadSequence): + expected := int64(0) + if cur, ok := s.runtime.Current(t.Task, c.ClientID); ok { + expected = cur.NextSeq + } + jsonAPIError(w, http.StatusConflict, "sequence_mismatch", "guess sequence is stale", map[string]any{"next_seq": expected}) + default: + jsonOut(w, 500, false) + } + return + } + + if cfg.GuessLotteryMaxAccepted > 0 { + selected, drawErr := s.lottery.enter(r.Context(), t.ID, c.ClientID, in.Seq, time.Duration(cfg.GuessLotteryWindowSec)*time.Second, cfg.GuessLotteryMaxAccepted) + if drawErr != nil { + switch { + case errors.Is(drawErr, errLotteryDuplicate): + jsonAPIError(w, http.StatusConflict, "lottery_duplicate", "guess is already waiting for the current draw", nil) + case errors.Is(drawErr, errLotteryFull): + jsonAPIError(w, http.StatusTooManyRequests, "lottery_full", "guess lottery window is full", nil) + case errors.Is(drawErr, context.Canceled), errors.Is(drawErr, context.DeadlineExceeded): + return + default: + jsonOut(w, 500, false) + } + return + } + + // A task may have been rerolled, paused or completed while this request + // waited for the draw. Never consume/evaluate a stale lottery ticket. + fresh, freshErr := s.store.SecretTask(r.Context(), id) + if freshErr != nil || fresh.Status != "active" { + jsonAPIError(w, http.StatusConflict, "task_inactive", "task is no longer active", nil) + return + } + if fresh.Paused { + jsonOut(w, 423, false) + return + } + if fresh.PublicSeed != t.PublicSeed || fresh.Revision != t.Revision { + jsonAPIError(w, http.StatusConflict, "task_config_changed", "task configuration changed during lottery; resync required", map[string]any{"revision": fresh.Revision}) + return + } + t = fresh + if !selected { + next, skipErr := s.runtime.SkipLottery(t.Task, c.ClientID, in.Seq, minInterval) + if skipErr != nil { + if errors.Is(skipErr, rtx.ErrBadSequence) { + jsonAPIError(w, http.StatusConflict, "sequence_mismatch", "guess sequence changed while waiting for lottery", map[string]any{"next_seq": next}) + } else if errors.Is(skipErr, rtx.ErrRateLimited) { + jsonOut(w, 429, false) + } else { + jsonOut(w, 500, false) + } + return + } + jsonAPIError(w, http.StatusTooManyRequests, "lottery_not_selected", "guess was not selected in this lottery window", map[string]any{"next_seq": next}) + return + } + } + d, err := core.Distance(in.Guess, t.Secret) if err != nil { jsonOut(w, 400, false) @@ -504,12 +580,7 @@ func (s *Server) guess(w http.ResponseWriter, r *http.Request) { } correct := d.Sign() == 0 score := core.Score(d, t.RangeBits) - if _, ok := s.runtime.Current(t.Task, c.ClientID); !ok { - snap, _ := s.store.LoadGuessState(r.Context(), t.ID, c.ClientID) - s.runtime.InitGuess(t.Task, c.ClientID, rtx.GuessState{NextSeq: snap.NextSeq, LastGuess: snap.LastGuess, BestScore: snap.BestScore, GuessCount: snap.GuessCount}) - } - serverMin, _ := taskIntervals(t.Task, s.settings.Get()) - accepted, err := s.runtime.Accept(t.Task, c.ClientID, in.Seq, score, time.Duration(serverMin)*time.Second) + accepted, err := s.runtime.Accept(t.Task, c.ClientID, in.Seq, score, minInterval) if err != nil { switch { case errors.Is(err, rtx.ErrRateLimited): diff --git a/internal/settings/settings.go b/internal/settings/settings.go index 9727cbf..ae3d5b0 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -15,6 +15,8 @@ import ( type Runtime struct { GuessMinIntervalSec int `json:"guess_min_interval_sec"` ClientSubmitIntervalSec int `json:"client_submit_interval_sec"` + GuessLotteryWindowSec int `json:"guess_lottery_window_sec"` + GuessLotteryMaxAccepted int `json:"guess_lottery_max_accepted"` TaskRangeBits int `json:"task_range_bits"` ActiveTaskCount int `json:"active_task_count"` PresenceTTLSec int `json:"presence_ttl_sec"` @@ -57,6 +59,8 @@ func Defaults() Runtime { return Runtime{ GuessMinIntervalSec: envInt("DEFAULT_GUESS_MIN_INTERVAL_SEC", 10), ClientSubmitIntervalSec: envInt("DEFAULT_CLIENT_SUBMIT_INTERVAL_SEC", 11), + GuessLotteryWindowSec: envInt("DEFAULT_GUESS_LOTTERY_WINDOW_SEC", 60), + GuessLotteryMaxAccepted: envInt("DEFAULT_GUESS_LOTTERY_MAX_ACCEPTED", 0), TaskRangeBits: envInt("DEFAULT_TASK_RANGE_BITS", 28), ActiveTaskCount: envInt("DEFAULT_ACTIVE_TASK_COUNT", 1), PresenceTTLSec: envInt("DEFAULT_PRESENCE_TTL_SEC", 35), @@ -199,6 +203,12 @@ func Validate(v Runtime) error { if v.ClientSubmitIntervalSec <= v.GuessMinIntervalSec || v.ClientSubmitIntervalSec > 7200 { return fmt.Errorf("client_submit_interval_sec must be > guess_min_interval_sec and <= 7200") } + if v.GuessLotteryWindowSec < 1 || v.GuessLotteryWindowSec > 3600 { + return fmt.Errorf("guess_lottery_window_sec must be 1..3600") + } + if v.GuessLotteryMaxAccepted < 0 || v.GuessLotteryMaxAccepted > 100000 { + return fmt.Errorf("guess_lottery_max_accepted must be 0..100000 (0 disables lottery)") + } if v.TaskRangeBits < 8 || v.TaskRangeBits > 128 { return fmt.Errorf("task_range_bits must be 8..128") } diff --git a/internal/webui/dist/app.js b/internal/webui/dist/app.js index c6399ab..c282319 100644 --- a/internal/webui/dist/app.js +++ b/internal/webui/dist/app.js @@ -43,14 +43,24 @@ async function loadProtectedImage(path,img,admin=false){ const identityKey='neuralhunt.identity.v1'; const b64u=b=>{const a=b instanceof Uint8Array?b:new Uint8Array(b);let s='';a.forEach(x=>s+=String.fromCharCode(x));return btoa(s).replaceAll('+','-').replaceAll('/','_').replaceAll('=','')}; const ub64=s=>{s=s.replaceAll('-','+').replaceAll('_','/');while(s.length%4)s+='=';const x=atob(s);return Uint8Array.from(x,c=>c.charCodeAt(0))}; -async function ensureIdentity(){const raw=localStorage.getItem(identityKey);if(raw)return JSON.parse(raw);const kp=await crypto.subtle.generateKey({name:'ECDSA',namedCurve:'P-256'},true,['sign','verify']);const b={version:1,publicJwk:await crypto.subtle.exportKey('jwk',kp.publicKey),privateJwk:await crypto.subtle.exportKey('jwk',kp.privateKey)};localStorage.setItem(identityKey,JSON.stringify(b));return b} -async function clientId(pub){const s=`${pub.kty}|${pub.crv}|${pub.x}|${pub.y}`;return b64u(await crypto.subtle.digest('SHA-256',new TextEncoder().encode(s)))} -async function sign(message){const b=await ensureIdentity();const k=await crypto.subtle.importKey('jwk',b.privateJwk,{name:'ECDSA',namedCurve:'P-256'},false,['sign']);return b64u(await crypto.subtle.sign({name:'ECDSA',hash:'SHA-256'},k,new TextEncoder().encode(message)))} +function requireWebCrypto(){ + const c=globalThis.crypto; + if(c?.subtle)return c.subtle; + const host=location.hostname; + const local=host==='localhost'||host==='127.0.0.1'||host==='::1'||host==='[::1]'; + if(location.protocol!=='https:'&&!local){ + throw new Error(`Sichere Verbindung erforderlich: Neural Hunt verwendet Browser-WebCrypto für deine lokale Identität. Öffne ${location.host} über HTTPS statt HTTP.`); + } + 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 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})`}} 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();const 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})});if(!r.ok)throw new Error(await responseError(r,'Login fehlgeschlagen'));return r.json()} -async function deterministicGuess(taskID,seed,cid,seq,bits){const h=new Uint8Array(await crypto.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<>>0;s=String(s||'');for(let i=0;i>>0} function pseudo(s,o=0){return ((Math.sin((hashInt(`${s}:${o}`)+1)*0.00000137+o*12.345)*43758.5453123)%1+1)%1} @@ -350,7 +360,7 @@ 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}`),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!':'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;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;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==='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}} 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?token=${encodeURIComponent(getToken())}&max_nodes=${encodeURIComponent(mx)}`);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}} 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=`
Startfehler${esc(e.message||'')}
`} @@ -418,8 +428,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','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)',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','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)',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}`; @@ -428,11 +438,11 @@ 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=`${o.connected} verbunden${o.clients} Identitäten${o.active_tasks} aktive Tasks${o.completed_tasks} abgeschlossen${Number(o.guesses||0).toLocaleString('de-DE')} Tipps${o.artifacts_ready} Artefakte`} - function renderPerformance(p){const r=p?.runtime||{},w=p?.websocket||{},x=p?.process||{};$('performance').innerHTML=`${Number(r.guesses_per_sec||0).toFixed(1)} Guess/s${Number(r.improvements_per_sec||0).toFixed(1)} Improve/s${Number(r.sqlite_writes_per_sec||0).toFixed(1)} SQLite W/s${Number(w.frames_per_sec||0).toFixed(0)} WS Frames/s${(Number(w.bytes_per_sec||0)/1048576).toFixed(2)} WS MB/s${Number(w.dropped_per_sec||0).toFixed(1)} Drops/s${Number(x.goroutines||0).toLocaleString('de-DE')} Goroutines${(Number(x.heap_bytes||0)/1048576).toFixed(1)} Heap MB`} + function renderPerformance(p){const r=p?.runtime||{},w=p?.websocket||{},x=p?.process||{};$('performance').innerHTML=`${Number(r.guesses_per_sec||0).toFixed(1)} Guess/s${Number(r.rejected_per_sec||0).toFixed(1)} Reject/s${Number(r.improvements_per_sec||0).toFixed(1)} Improve/s${Number(r.sqlite_writes_per_sec||0).toFixed(1)} SQLite W/s${Number(w.frames_per_sec||0).toFixed(0)} WS Frames/s${(Number(w.bytes_per_sec||0)/1048576).toFixed(2)} WS MB/s${Number(w.dropped_per_sec||0).toFixed(1)} Drops/s${Number(x.goroutines||0).toLocaleString('de-DE')} Goroutines${(Number(x.heap_bytes||0)/1048576).toFixed(1)} Heap MB`} async function openAdminFile(taskID,kind){const popup=window.open('','_blank');try{const token=localStorage.getItem(adminTokenKey),r=await fetch(`/api/admin/tasks/${encodeURIComponent(taskID)}/${kind}`,{headers:{Authorization:'Bearer '+token}});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=>``).join(''):'
Keine Tasks
';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=`
GLOBAL RUNTIME
${runtimeKeys.map(k=>``).join('')}

Diese Defaults gelten für neue Tasks. Task-spezifische Intervalle und Zahlenräume steuerst du im Tab „Task Actions“.

+ $('settingfields').innerHTML=`
GLOBAL RUNTIME
${runtimeKeys.map(k=>``).join('')}

Die Tipp-Lotterie gilt getrennt pro aktivem Task. Bei einem Wert > 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. 0 = Lotterie aus. Änderungen greifen für neu beginnende Fenster.

Die übrigen Defaults gelten global. Task-spezifische Intervalle und Zahlenräume steuerst du im Tab „Task Actions“.

ALTE PROFILE BEREINIGEN

Löscht ausschließlich Accounts, die seit mindestens X Zeit inaktiv, aktuell nicht verbunden und niemals Gewinner eines Tasks waren. Gewinner werden unabhängig vom Alter immer geschützt. Zugehörige Punkte, Unlocks und Task-Auswahl werden mit dem Profil entfernt.

Noch nicht geprüft.