Files
neural-hunt/cmd/client/ui.go
jbergner 005fd6ca51
Some checks failed
release-tag / release-image (push) Failing after 1m18s
RC-1
2026-08-10 05:48:55 +02:00

783 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}