Files
neural-hunt/cmd/client/ui.go
groot 28e125ffe3
All checks were successful
release-tag / release-image (push) Successful in 4m50s
RC-13
2026-08-13 12:40:51 +02:00

912 lines
25 KiB
Go
Raw Permalink 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"
"crypto/sha256"
"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 {
rootCtx context.Context
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
beaconPathMode string
}
func newApp(rootCtx context.Context, api *apiClient, identityPath, passphrase string, maxNodes int, quiet, unattended bool) *app {
if maxNodes < 50 {
maxNodes = 50
}
if maxNodes > 10000 {
maxNodes = 10000
}
return &app{rootCtx: rootCtx, 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) {
// The server emits a WebSocket ping every 30 seconds. A client-side read
// deadline is equally important: without one, a half-open TCP connection
// (Wi-Fi/VPN/NAT outage) can block ReadMessage forever and wsConnected would
// incorrectly remain true. Receiving either data or a server ping refreshes
// the deadline; 90 seconds of silence forces the normal reconnect loop.
const idleTimeout = 90 * time.Second
_ = conn.SetReadDeadline(time.Now().Add(idleTimeout))
defaultPing := conn.PingHandler()
conn.SetPingHandler(func(appData string) error {
_ = conn.SetReadDeadline(time.Now().Add(idleTimeout))
return defaultPing(appData)
})
for {
_, b, err := conn.ReadMessage()
if err != nil {
if ctx.Err() != nil {
return false, ctx.Err()
}
return false, err
}
_ = conn.SetReadDeadline(time.Now().Add(idleTimeout))
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 a.retryCurrentTaskAfterCompletion(taskID)
return true, nil
}
}
}
}
// retryCurrentTaskAfterCompletion closes a gap that previously left unattended
// workers idle forever when the successor task was not yet queryable on the
// first 450ms attempt. It keeps retrying with bounded backoff until either a
// new active task is available or the whole client is shutting down.
func (a *app) retryCurrentTaskAfterCompletion(completedTaskID string) {
backoff := 500 * time.Millisecond
for {
select {
case <-a.rootCtx.Done():
return
case <-time.After(backoff):
}
if err := a.startTask(a.rootCtx, ""); err == nil {
return
} else if !errors.Is(err, errSwitching) && !a.quiet {
fmt.Printf("[task] Folge-Task noch nicht bereit: %v; neuer Versuch folgt\n", err)
}
backoff = time.Duration(minInt64(int64(15*time.Second), int64(float64(backoff)*1.7)))
a.mu.RLock()
currentID := a.task.ID
a.mu.RUnlock()
if currentID != "" && currentID != completedTaskID {
return
}
}
}
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.clientID() {
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.clientID()]; 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 chooseBeaconPath(mode, clientID string, seq int64) string {
switch strings.ToUpper(strings.TrimSpace(mode)) {
case "PULSE", "FLUX", "ORBIT":
return strings.ToUpper(strings.TrimSpace(mode))
}
h := sha256.Sum256([]byte(fmt.Sprintf("nh-cli-beacon-auto|%s|%d", clientID, seq)))
paths := []string{"PULSE", "FLUX", "ORBIT"}
return paths[int(h[0])%len(paths)]
}
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
}
path := ""
if t.BeaconHuntEnabled == 1 && t.GuessLotteryMaxAccepted > 0 {
path = chooseBeaconPath(a.beaconPathMode, a.api.clientID(), seq)
if !a.quiet {
fmt.Printf("[beacon] Pfad %s · Bonusgewicht bei Treffer ×%d\n", path, t.BeaconBonusWeight)
}
}
correct, err := a.api.guess(ctx, t, seq, path)
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.clientID())
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.clientID()
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.clientID() {
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) printMyNFTs(ctx context.Context, limit int) error {
if limit <= 0 {
limit = 30
}
items, err := a.api.ownedArtifacts(ctx, limit)
if err != nil {
return err
}
fmt.Println("\nMEINE NFTS / ORIGINAL-ARTEFAKTE")
fmt.Println("──────────────────────────────────────────────────────────────────────────")
for i, n := range items {
name := strings.TrimSpace(n.DisplayName)
if name == "" {
name = shortID(n.TaskID)
}
fmt.Printf("%2d %-24s task=%-16s %3dbit %s\n", i+1, name, shortID(n.TaskID), n.RangeBits, n.CompletedAt.Local().Format("2006-01-02 15:04"))
fmt.Printf(" Original: %s%s\n", a.api.base, n.DownloadURI)
}
if len(items) == 0 {
fmt.Println("(für diese Identität noch keine fertigen Gewinner-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], my-nfts [n], nft get <task-id> <datei>, nft original <task-id> <datei>, identity, identity export <datei>, hosted-code, 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] öffentliche Wasserzeichen-NFTs anzeigen")
fmt.Println(" my-nfts [n] eigene Gewinner-Artefakte anzeigen")
fmt.Println(" nft get <task-id> <datei> Wasserzeichen-Preview speichern")
fmt.Println(" nft original <task-id> <datei> eigenes Original speichern")
fmt.Println(" identity Client-ID und Identity-Datei")
fmt.Println(" identity export <datei> browser-kompatiblen verschlüsselten Export schreiben")
fmt.Println(" hosted-code 10-Minuten-Code zum Koppeln als Reward-Identität")
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 "my-nfts", "mine":
n := 30
if len(parts) > 1 {
n, _ = strconv.Atoi(parts[1])
}
if err := a.printMyNFTs(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 if len(parts) == 4 && (strings.EqualFold(parts[1], "original") || strings.EqualFold(parts[1], "download")) {
if err := a.api.downloadOwnedArtifact(ctx, parts[2], parts[3]); err != nil {
fmt.Println("Fehler:", err)
} else {
fmt.Println("Original-Artefakt gespeichert:", parts[3])
}
} else {
fmt.Println("nft get <task-id> <datei> | nft original <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.clientID())
fmt.Println("Identity :", a.identityPath)
}
case "hosted-code", "hosted-link":
x, err := a.api.hostedLinkCode(ctx)
if err != nil {
fmt.Println("Fehler:", err)
} else {
fmt.Println("Hosted-Code:", x.Code)
fmt.Println("Client-ID :", x.ClientID)
fmt.Println("Gültig bis :", x.ExpiresAt.Local().Format(time.RFC3339))
}
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)
if err == nil {
return t.ID, nil
}
// A hosted worker stores the task ID that was active when it was created.
// After that task completes, the game moves the identity to its successor,
// but the Customer-Service record may still contain the historical ID. On
// a later container restart, falling back to the server-selected/current
// active task lets the same persistent identity resume instead of crash-
// looping forever on "task not found".
if !interactive && strings.TrimSpace(os.Getenv("NEURALHUNT_WORKER_ID")) != "" {
for _, candidate := range ts {
if candidate.Selected {
if !a.quiet {
fmt.Printf("[task] konfigurierte Task-ID %q ist nicht mehr aktiv; setze mit %s fort\n", selector, taskName(candidate))
}
return candidate.ID, nil
}
}
if !a.quiet {
fmt.Printf("[task] konfigurierte Task-ID %q ist nicht mehr aktiv; verwende %s\n", selector, taskName(ts[0]))
}
return ts[0].ID, nil
}
return "", 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
}