RC-1
release-tag / release-image (push) Failing after 1m18s

This commit is contained in:
2026-08-10 05:48:55 +02:00
parent 4f0ac14c49
commit 005fd6ca51
52 changed files with 9020 additions and 1 deletions
+782
View File
@@ -0,0 +1,782 @@
package main
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
)
type wsEvent struct {
Type string `json:"type"`
TaskID string `json:"task_id"`
Data json.RawMessage `json:"data"`
}
type app struct {
api *apiClient
identityPath string
passphrase string
maxNodes int
quiet bool
unattended bool
mu sync.RWMutex
task taskDTO
seq int64
points map[string]point
me meDTO
ws *websocket.Conn
wsConnected bool
sessionCancel context.CancelFunc
switching bool
leaderWatch bool
lastGuessAt time.Time
lastGuessOK bool
}
func newApp(api *apiClient, identityPath, passphrase string, maxNodes int, quiet, unattended bool) *app {
if maxNodes < 50 {
maxNodes = 50
}
if maxNodes > 10000 {
maxNodes = 10000
}
return &app{api: api, identityPath: identityPath, passphrase: passphrase, maxNodes: maxNodes, quiet: quiet, unattended: unattended, points: make(map[string]point)}
}
func shortID(s string) string {
if len(s) <= 12 {
return s
}
return s[:6] + "…" + s[len(s)-4:]
}
func taskName(t taskCard) string {
if strings.TrimSpace(t.DisplayName) != "" {
return strings.TrimSpace(t.DisplayName)
}
return "Task " + shortID(t.ID)
}
func dtoName(t taskDTO) string {
if strings.TrimSpace(t.DisplayName) != "" {
return strings.TrimSpace(t.DisplayName)
}
return "Task " + shortID(t.ID)
}
func (a *app) printTasks(ctx context.Context) ([]taskCard, error) {
ts, err := a.api.tasks(ctx)
if err != nil {
return nil, err
}
fmt.Println("\nACTIVE TASKS")
fmt.Println("────────────────────────────────────────────────────────────────────────────")
for i, t := range ts {
mark := " "
if t.Selected {
mark = "*"
}
state := "RUN"
if t.Paused {
state = "PAUSED"
}
rank := "—"
if t.OwnRank > 0 {
rank = fmt.Sprintf("#%d", t.OwnRank)
}
fmt.Printf("%s %2d %-24s %3dbit %-6s nodes=%-5d score=%7.3f rank=%s\n", mark, i+1, clip(taskName(t), 24), t.RangeBits, state, t.PointCount, t.OwnScore, rank)
if d := strings.TrimSpace(t.Description); d != "" {
fmt.Printf(" %s\n", clip(d, 70))
}
fmt.Printf(" id=%s\n", t.ID)
}
if len(ts) == 0 {
fmt.Println("(keine aktiven Tasks)")
}
fmt.Println()
return ts, nil
}
func clip(s string, n int) string {
r := []rune(strings.TrimSpace(s))
if len(r) <= n {
return string(r)
}
if n < 2 {
return string(r[:n])
}
return string(r[:n-1]) + "…"
}
func resolveTask(tasks []taskCard, selector string) (taskCard, error) {
selector = strings.TrimSpace(selector)
if selector == "" {
for _, t := range tasks {
if t.Selected {
return t, nil
}
}
if len(tasks) > 0 {
return tasks[0], nil
}
return taskCard{}, fmt.Errorf("no active task")
}
if n, err := strconv.Atoi(selector); err == nil && n >= 1 && n <= len(tasks) {
return tasks[n-1], nil
}
low := strings.ToLower(selector)
var matches []taskCard
for _, t := range tasks {
if strings.EqualFold(t.ID, selector) || strings.HasPrefix(strings.ToLower(t.ID), low) || strings.Contains(strings.ToLower(t.DisplayName), low) {
matches = append(matches, t)
}
}
if len(matches) == 1 {
return matches[0], nil
}
if len(matches) > 1 {
return taskCard{}, fmt.Errorf("task selector %q is ambiguous", selector)
}
return taskCard{}, fmt.Errorf("task %q not found", selector)
}
func (a *app) stopSession() {
a.mu.Lock()
cancel := a.sessionCancel
conn := a.ws
a.sessionCancel = nil
a.ws = nil
a.wsConnected = false
a.mu.Unlock()
if cancel != nil {
cancel()
}
if conn != nil {
_ = conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "switch task"), time.Now().Add(200*time.Millisecond))
_ = conn.Close()
}
// Give the server's single-identity presence cleanup a short chance to run.
time.Sleep(120 * time.Millisecond)
}
func (a *app) startTask(ctx context.Context, taskID string) error {
a.mu.Lock()
if a.switching {
a.mu.Unlock()
return errSwitching
}
a.switching = true
a.mu.Unlock()
defer func() {
a.mu.Lock()
a.switching = false
a.mu.Unlock()
}()
a.stopSession()
var (
t taskDTO
err error
)
if taskID != "" {
t, err = a.api.selectTask(ctx, taskID)
} else {
t, err = a.api.currentTask(ctx)
}
if err != nil {
return err
}
ws, err := a.api.dialWS(ctx, a.maxNodes)
if err != nil {
return err
}
ps, _ := a.api.points(ctx, t.ID, a.maxNodes*3)
m, _ := a.api.me(ctx)
sctx, cancel := context.WithCancel(context.Background())
a.mu.Lock()
a.task, a.seq, a.ws, a.sessionCancel, a.me = t, t.NextSeq, ws, cancel, m
a.wsConnected = true
a.points = make(map[string]point, len(ps))
for _, p := range ps {
a.points[p.ClientID] = p
}
a.mu.Unlock()
go a.wsLoop(sctx, ws, t.ID)
go a.guessLoop(sctx, t.ID)
if !a.quiet {
fmt.Printf("\n▶ %s [%s] %d bit\n", dtoName(t), shortID(t.ID), t.RangeBits)
if t.Paused {
fmt.Println(" Task ist pausiert; automatische Tipps warten.")
}
}
return nil
}
func (a *app) wsLoop(ctx context.Context, initial *websocket.Conn, taskID string) {
conn := initial
backoff := 500 * time.Millisecond
for {
completed, err := a.readWSOnce(ctx, conn, taskID)
a.mu.Lock()
if a.ws == conn {
a.ws = nil
a.wsConnected = false
}
a.mu.Unlock()
if completed || ctx.Err() != nil {
return
}
if err != nil && !a.quiet {
fmt.Printf("\n[ws] Verbindung beendet: %v; Reconnect folgt automatisch\n", err)
}
for {
timer := time.NewTimer(backoff)
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
}
next, dialErr := a.api.dialWS(ctx, a.maxNodes)
if dialErr != nil {
if !a.quiet {
fmt.Printf("[ws] Reconnect fehlgeschlagen: %v\n", dialErr)
}
backoff = time.Duration(minInt64(int64(8*time.Second), int64(float64(backoff)*1.7)))
continue
}
a.mu.Lock()
if a.task.ID != taskID || ctx.Err() != nil {
a.mu.Unlock()
_ = next.Close()
return
}
a.ws = next
a.wsConnected = true
a.mu.Unlock()
conn = next
backoff = 500 * time.Millisecond
go a.refreshTask(taskID)
if !a.quiet {
fmt.Println("[ws] wieder verbunden")
}
break
}
}
}
func minInt64(a, b int64) int64 {
if a < b {
return a
}
return b
}
func (a *app) forceWSReconnect() {
a.mu.Lock()
conn := a.ws
a.ws = nil
a.wsConnected = false
a.mu.Unlock()
if conn != nil {
_ = conn.Close()
}
}
func (a *app) readWSOnce(ctx context.Context, conn *websocket.Conn, taskID string) (bool, error) {
for {
_, b, err := conn.ReadMessage()
if err != nil {
if ctx.Err() != nil {
return false, ctx.Err()
}
return false, err
}
var ev wsEvent
if json.Unmarshal(b, &ev) != nil {
continue
}
switch ev.Type {
case "snapshot":
var ps []point
if json.Unmarshal(ev.Data, &ps) == nil {
a.replacePoints(ps)
}
case "point":
var p point
if json.Unmarshal(ev.Data, &p) == nil {
a.upsertPoint(p)
}
case "points":
var ps []point
if json.Unmarshal(ev.Data, &ps) == nil {
for _, p := range ps {
a.upsertPoint(p)
}
}
case "task_changed":
if ev.TaskID == "" || ev.TaskID == taskID {
go a.refreshTask(taskID)
}
case "task_completed":
if ev.TaskID == taskID {
if !a.quiet {
fmt.Println("\n✓ Task abgeschlossen. Wechsle auf den Folge-Task …")
}
go func() {
time.Sleep(450 * time.Millisecond)
if err := a.startTask(context.Background(), ""); err != nil && !a.quiet {
fmt.Printf("[task] Folge-Task noch nicht bereit: %v\n", err)
}
}()
return true, nil
}
}
}
}
func (a *app) replacePoints(ps []point) {
a.mu.Lock()
defer a.mu.Unlock()
a.points = make(map[string]point, len(ps))
for _, p := range ps {
a.points[p.ClientID] = p
}
}
func (a *app) upsertPoint(p point) {
a.mu.Lock()
defer a.mu.Unlock()
if a.points == nil {
a.points = make(map[string]point)
}
a.points[p.ClientID] = p
if p.ClientID == a.api.cid {
a.me.Score, a.me.Rank = p.Score, p.Rank
}
if len(a.points) > a.maxNodes*3 {
a.trimPointsLocked()
}
}
func (a *app) trimPointsLocked() {
ps := make([]point, 0, len(a.points))
for _, p := range a.points {
ps = append(ps, p)
}
sort.Slice(ps, func(i, j int) bool { return ps[i].Score > ps[j].Score })
keep := a.maxNodes * 3
if keep > len(ps) {
keep = len(ps)
}
next := make(map[string]point, keep+1)
for _, p := range ps[:keep] {
next[p.ClientID] = p
}
if own, ok := a.points[a.api.cid]; ok {
next[own.ClientID] = own
}
a.points = next
}
func (a *app) refreshTask(taskID string) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
t, err := a.api.currentTask(ctx)
if err != nil || t.ID != taskID {
return
}
ps, _ := a.api.points(ctx, t.ID, a.maxNodes*3)
m, _ := a.api.me(ctx)
a.mu.Lock()
oldSeed := a.task.PublicSeed
a.task = t
// Preserve the hot sequence for same-seed changes (bits/intervals/config).
// A reroll changes public_seed and intentionally resets to the server state.
if oldSeed != t.PublicSeed {
a.seq = t.NextSeq
} else if t.NextSeq > a.seq {
a.seq = t.NextSeq
}
if m.ClientID != "" {
a.me = m
}
if ps != nil {
a.points = make(map[string]point, len(ps))
for _, p := range ps {
a.points[p.ClientID] = p
}
}
a.mu.Unlock()
if !a.quiet {
fmt.Printf("\n[task] Konfiguration aktualisiert: %d bit, Intervall %ds, paused=%v\n", t.RangeBits, t.ClientSubmitIntervalSec, t.Paused)
}
}
func (a *app) guessLoop(ctx context.Context, taskID string) {
for {
a.mu.RLock()
t := a.task
a.mu.RUnlock()
if t.ID != taskID {
return
}
sec := t.ClientSubmitIntervalSec
if sec <= 0 {
sec = 11
}
timer := time.NewTimer(time.Duration(sec) * time.Second)
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
}
// Re-read state after sleeping. Admin actions may have changed seed/bits,
// sequence or pause state while this iteration was waiting.
a.mu.RLock()
t = a.task
seq := a.seq
connected := a.wsConnected
a.mu.RUnlock()
if t.ID != taskID {
return
}
if t.Paused || !connected {
continue
}
correct, err := a.api.guess(ctx, t, seq)
if err != nil {
if ctx.Err() != nil {
return
}
if ae := new(apiError); errorsAs(err, &ae) && (ae.Status == 409 || ae.Status == 423 || ae.Status == 429) {
a.refreshTask(taskID)
if ae.Status == 409 && ae.Code() == "presence_required" {
a.forceWSReconnect()
}
} else if !a.quiet {
fmt.Printf("\n[guess] %v\n", err)
}
continue
}
a.mu.Lock()
if a.task.ID == taskID && a.seq == seq {
a.seq++
}
a.lastGuessAt = time.Now()
a.lastGuessOK = true
a.mu.Unlock()
if correct {
fmt.Printf("\n★ GEWONNEN: %s ★\n", dtoName(t))
return
}
}
}
// errorsAs is a tiny wrapper to keep the UI file's imports readable.
func errorsAs(err error, target any) bool { return errors.As(err, target) }
func (a *app) printStatus(ctx context.Context) {
m, err := a.api.me(ctx)
if err == nil {
a.mu.Lock()
a.me = m
a.mu.Unlock()
}
a.mu.RLock()
t, seq, points, me, last := a.task, a.seq, len(a.points), a.me, a.lastGuessAt
a.mu.RUnlock()
fmt.Println("\nSTATUS")
fmt.Println("────────────────────────────────────────────────────────")
fmt.Printf("Identity : %s\n", a.api.cid)
fmt.Printf("Task : %s (%s)\n", dtoName(t), t.ID)
fmt.Printf("Raum : %d bit Revision %d Paused %v\n", t.RangeBits, t.Revision, t.Paused)
fmt.Printf("Score : %.4f Rank #%d Wins %d\n", me.Score, me.Rank, me.Wins)
fmt.Printf("Sequence : %d Nodes im Working Set %d\n", seq, points)
if !last.IsZero() {
fmt.Printf("Letzter Tipp: %s\n", last.Format("15:04:05"))
}
if len(me.Unlocks) > 0 {
fmt.Printf("Unlocks : %s\n", strings.Join(me.Unlocks, ", "))
}
fmt.Println()
}
func (a *app) printMap(limit int) {
if limit <= 0 {
limit = 30
}
a.mu.RLock()
ps := make([]point, 0, len(a.points))
for _, p := range a.points {
ps = append(ps, p)
}
cid := a.api.cid
t := a.task
a.mu.RUnlock()
sort.Slice(ps, func(i, j int) bool {
if ps[i].Score == ps[j].Score {
return ps[i].ClientID < ps[j].ClientID
}
return ps[i].Score > ps[j].Score
})
if len(ps) > limit {
ps = ps[:limit]
}
fmt.Printf("\nTARGET FIELD — %s\n", dtoName(t))
fmt.Println("TASK ◉ ← höherer Score bedeutet näher am Zentrum")
fmt.Println("────────────────────────────────────────────────────────────────")
bands := []struct {
name string
min float64
max float64
}{{"99+ INNER CORE", 99, 101}, {"9599 NEAR", 95, 99}, {"9095 CLOSE", 90, 95}, {"7590 MID", 75, 90}, {"5075 FAR", 50, 75}, {"050 OUTER", 0, 50}}
for _, b := range bands {
var names []string
for _, p := range ps {
if p.Score >= b.min && p.Score < b.max {
mark := ""
if p.ClientID == cid {
mark = "*"
}
names = append(names, fmt.Sprintf("%s%s %.3f", mark, shortID(p.ClientID), p.Score))
}
}
fmt.Printf("%-14s │ %s\n", b.name, strings.Join(names, " "))
}
fmt.Println("* = deine Identität")
fmt.Println()
}
func (a *app) printLeaderboard(ctx context.Context, limit int) error {
ls, err := a.api.leaderboard(ctx)
if err != nil {
return err
}
if limit <= 0 || limit > len(ls) {
limit = minInt(25, len(ls))
}
fmt.Println("\nREALTIME LEADERBOARD")
fmt.Println("──────────────────────────────────────────────────────────────────────────")
fmt.Printf("%-5s %-15s %6s %10s %10s %8s %5s\n", "RANK", "CLIENT", "WINS", "LIVE", "BEST", "GUESSES", "NFT")
for i, l := range ls[:limit] {
conn := " "
if l.Connected {
conn = "●"
}
self := ""
if l.ClientID == a.api.cid {
self = "*"
}
fmt.Printf("#%-4d %-15s %6d %10.4f %10.4f %8d %5d\n", i+1, conn+self+shortID(l.ClientID), l.Wins, l.LiveScore, l.BestScore, l.GuessCount, l.NFTCount)
}
fmt.Println("● online * du")
fmt.Println()
return nil
}
func (a *app) printNFTs(ctx context.Context, limit int) error {
if limit <= 0 {
limit = 30
}
items, err := a.api.artifacts(ctx, limit)
if err != nil {
return err
}
fmt.Println("\nNFT / WINNER ARTIFACTS (öffentliche Wasserzeichen-Previews)")
fmt.Println("──────────────────────────────────────────────────────────────────────────")
for i, n := range items {
fmt.Printf("%2d task=%-16s winner=%-14s %3dbit %s\n", i+1, shortID(n.TaskID), shortID(n.WinnerClientID), n.RangeBits, n.CompletedAt.Local().Format("2006-01-02 15:04"))
fmt.Printf(" %s%s\n", a.api.base, n.PreviewURI)
}
if len(items) == 0 {
fmt.Println("(noch keine fertigen Artefakte)")
}
fmt.Println()
return nil
}
func (a *app) watcher(ctx context.Context) {
t := time.NewTicker(5 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
a.mu.RLock()
watch := a.leaderWatch
task := a.task
seq := a.seq
me := a.me
a.mu.RUnlock()
if watch {
_ = a.printLeaderboard(ctx, 15)
} else if a.unattended && !a.quiet {
fmt.Printf("%s task=%s score=%.4f rank=%d seq=%d paused=%v\n", time.Now().Format("15:04:05"), shortID(task.ID), me.Score, me.Rank, seq, task.Paused)
}
}
}
}
func (a *app) commandLoop(ctx context.Context, in io.Reader) error {
s := bufio.NewScanner(in)
fmt.Println("Befehle: help, tasks, use <nr|id|name>, status, map [n], leaderboard [n|watch|stop], nfts [n], nft get <task-id> <datei>, identity, identity export <datei>, quit")
for {
fmt.Print("neuralhunt> ")
if !s.Scan() {
return s.Err()
}
line := strings.TrimSpace(s.Text())
if line == "" {
continue
}
parts := strings.Fields(line)
cmd := strings.ToLower(parts[0])
switch cmd {
case "help", "?":
fmt.Println(" tasks aktive Tasks anzeigen")
fmt.Println(" use <nr|id|name> Task wechseln")
fmt.Println(" status Score, Rank, Sequence, Task")
fmt.Println(" map [n] textuelles Target Field / Nähe")
fmt.Println(" leaderboard [n] Live-Rangliste")
fmt.Println(" leaderboard watch|stop Rangliste alle 5s ein/aus")
fmt.Println(" nfts [n] Wasserzeichen-NFTs anzeigen")
fmt.Println(" nft get <task-id> <datei> Wasserzeichen-Preview speichern")
fmt.Println(" identity Client-ID und Identity-Datei")
fmt.Println(" identity export <datei> browser-kompatiblen verschlüsselten Export schreiben")
fmt.Println(" quit beenden")
case "tasks":
if _, err := a.printTasks(ctx); err != nil {
fmt.Println("Fehler:", err)
}
case "use":
if len(parts) < 2 {
fmt.Println("use benötigt Nummer, ID-Präfix oder Namen")
continue
}
ts, err := a.api.tasks(ctx)
if err != nil {
fmt.Println("Fehler:", err)
continue
}
t, err := resolveTask(ts, strings.Join(parts[1:], " "))
if err != nil {
fmt.Println("Fehler:", err)
continue
}
if err := a.startTask(ctx, t.ID); err != nil {
fmt.Println("Fehler:", err)
}
case "status":
a.printStatus(ctx)
case "map":
n := 30
if len(parts) > 1 {
n, _ = strconv.Atoi(parts[1])
}
a.printMap(n)
case "leaderboard", "lb":
if len(parts) > 1 && strings.EqualFold(parts[1], "watch") {
a.mu.Lock()
a.leaderWatch = true
a.mu.Unlock()
fmt.Println("Leaderboard-Watch aktiviert.")
continue
}
if len(parts) > 1 && (strings.EqualFold(parts[1], "stop") || strings.EqualFold(parts[1], "off")) {
a.mu.Lock()
a.leaderWatch = false
a.mu.Unlock()
fmt.Println("Leaderboard-Watch deaktiviert.")
continue
}
n := 25
if len(parts) > 1 {
n, _ = strconv.Atoi(parts[1])
}
if err := a.printLeaderboard(ctx, n); err != nil {
fmt.Println("Fehler:", err)
}
case "nfts":
n := 30
if len(parts) > 1 {
n, _ = strconv.Atoi(parts[1])
}
if err := a.printNFTs(ctx, n); err != nil {
fmt.Println("Fehler:", err)
}
case "nft":
if len(parts) == 4 && strings.EqualFold(parts[1], "get") {
if err := a.api.downloadPreview(ctx, parts[2], parts[3]); err != nil {
fmt.Println("Fehler:", err)
} else {
fmt.Println("Wasserzeichen-Preview gespeichert:", parts[3])
}
} else {
fmt.Println("nft get <task-id> <datei>")
}
case "identity", "id":
if len(parts) >= 2 && strings.EqualFold(parts[1], "export") {
if len(parts) != 3 {
fmt.Println("identity export <datei>")
continue
}
if err := exportBrowserIdentity(parts[2], a.passphrase, a.api.id); err != nil {
fmt.Println("Fehler:", err)
} else {
fmt.Println("Verschlüsselter Browser-Export geschrieben:", parts[2])
}
} else {
fmt.Println("Client-ID:", a.api.cid)
fmt.Println("Identity :", a.identityPath)
}
case "quit", "exit", "q":
return nil
default:
fmt.Println("Unbekannter Befehl. 'help' zeigt die Befehle.")
}
}
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
func selectInitialTask(ctx context.Context, a *app, selector string, interactive bool) (string, error) {
ts, err := a.printTasks(ctx)
if err != nil {
return "", err
}
if len(ts) == 0 {
return "", fmt.Errorf("server has no active tasks")
}
if selector != "" || !interactive {
t, err := resolveTask(ts, selector)
return t.ID, err
}
fmt.Print("Task auswählen [Nummer/ID/Name, Enter = markierter Task]: ")
line, _ := bufio.NewReader(os.Stdin).ReadString('\n')
t, err := resolveTask(ts, strings.TrimSpace(line))
return t.ID, err
}