@@ -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}, {"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.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
|
||||
}
|
||||
Reference in New Issue
Block a user