912 lines
25 KiB
Go
912 lines
25 KiB
Go
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}, {"95–99 NEAR", 95, 99}, {"90–95 CLOSE", 90, 95}, {"75–90 MID", 75, 90}, {"50–75 FAR", 50, 75}, {"0–50 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
|
||
}
|