RC-11
All checks were successful
release-tag / release-image (push) Successful in 4m46s

This commit is contained in:
2026-08-12 22:26:00 +02:00
parent d76f1c3d25
commit f2da96ec24
16 changed files with 672 additions and 117 deletions

View File

@@ -1,3 +1,5 @@
> **V4.2.8 Admin Control & Ownership:** Customer-Admins können das Worker-Limit pro Benutzer aufheben und Worker direkt zuordnen. Server-Admins können Task-Serien als AUSLAUFEND markieren, Collectible-Eigentum ohne Umschreiben der historischen Gewinner-Provenienz übertragen und zufällige ADMIN-DROP-NFTs für bestehende Neural-Hunt-Identitäten erzeugen. Details: `V4.2.8_ADMIN_CONTROL_OWNERSHIP.md`.
> **V4.2.7 Admin Live Guess Flash:** In der privaten Server-Admin-3D-Map kann `TIPPS %` optional den Score jedes tatsächlich ausgewerteten Tipps kurz am Client-Knoten einblenden auch wenn er unterhalb des bisherigen Highscores liegt. Die flüchtigen Werte laufen ausschließlich über einen admin-authentifizierten WebSocket und werden nicht an Spieler oder das öffentliche Leaderboard gesendet. Details: `V4.2.7_ADMIN_GUESS_FLASH.md`.
> **V4.2.6.1 Build Fix:** Das erste V4.2.6-Release-Archiv enthielt versehentlich nicht `internal/data/`. Dadurch konnte neuer Server-Code mit einem alten Data-Package kombiniert werden und der Server-Build scheiterte mit fehlenden `HostedCreditEvent`/Outbox-Methoden. Dieses Archiv enthält den vollständigen Data-Layer. Details: `V4.2.6.1_BUILD_FIX.md`.

View File

@@ -501,3 +501,11 @@ CUSTOMER_SERVICE_INTERNAL_URL=http://customer-service:8092
10. Enable **Registrierung nur mit einmaligem Invite-Code**, create an invite in Customer Admin, register one account with it, then verify the same invite cannot create a second account.
For production/open registration, do not rely on PoW as a one-human/one-account guarantee. Keep Traefik/Caddy rate/concurrency limits on registration/login routes and consider invite-only registration when free signup credits have meaningful value.
## V4.2.8 Admin control / ownership smoke tests
1. Customer Admin: enable `LIMIT AUFHEBEN` for one account, create more workers than `CS_MAX_WORKERS_PER_CUSTOMER`, and verify only global inventory/running caps still stop creation/start.
2. Disable the bypass and verify the normal per-customer limits apply again.
3. Server Admin: set an active task to `AUSLAUFEND`, complete/close it, and verify there is no child task with `parent_task_id=<retired-id>`; if the active pool needs replacement, a new root task may appear.
4. Transfer a ready collectible to another client identity. Verify the new identity sees it under `MEINE NFTS`, the old identity no longer does, while the task's historical `winner_client_id` is unchanged.
5. Server Admin → Artifact: queue an `ADMIN NFT DROP` for a client. Verify the new `drop_*` task is `completed/pending`, artifact generation proceeds through the normal worker/circuit breaker, and the resulting collectible appears for the target identity without incrementing historical wins.

View File

@@ -0,0 +1,25 @@
# V4.2.8 Admin Control & Collectible Ownership
## Customer worker-limit override
The private Customer-Service admin UI can toggle `worker_limit_bypass` for an individual customer. When enabled, that customer is exempt from both per-customer worker inventory and per-customer running-worker limits. Global host limits remain active. Admins can also create stopped workers directly for a selected customer and assign an active task / Beacon mode.
## Retiring task series
Each task has `retire_after_completion`. A retiring task can finish normally and still mint its winner artifact, but `EnsureSuccessorTask` will not create the inherited follow-up task. If the configured active-task pool needs a replacement, `EnsureActiveTasks` creates a fresh root task from global defaults instead of cloning the retiring series.
## Transferable collectible ownership
Historical game provenance remains in `winner_client_id` and `winner_worker_client_id`. Current ownership is stored separately in `artifact_owner_client_id`. Admin transfers update only current ownership and append an immutable-style audit row to `artifact_transfers`; the original winner is not rewritten.
Existing databases remain compatible: rows without an explicit owner are interpreted as owned by `winner_client_id` through `COALESCE(artifact_owner_client_id,winner_client_id)`.
## Admin NFT drops
Server Admin → Artifact can queue 120 random collectibles for a chosen Neural Hunt identity. A template task may be selected; with no template the server chooses an existing normal win task at random for art direction. Each drop gets a fresh secret/seed/task id, is marked `artifact_origin=admin_drop`, starts in `artifact_status=pending`, has no historical game winner, and never creates a successor task.
Admin drops use the normal artifact worker, OpenAI circuit breaker, style/anchor pipeline, and therefore can incur the same provider cost as winner cards.
## Deployment
Rebuild `Dockerfile.server` and `Dockerfile.customer-service`. The worker image is unchanged.

View File

@@ -44,6 +44,12 @@ func renderCardSVG(art []byte, artExt string, x win, traits collectionTraits) []
rarity := html.EscapeString(traits.Rarity)
completed := x.Completed.UTC().Format("2006.01.02")
mime := imageMIME(artExt)
editionLabel := "WINNING EDITION"
bitLabel := fmt.Sprintf("%d-BIT HUNT", x.RangeBits)
if strings.EqualFold(strings.TrimSpace(x.Origin), "admin_drop") {
editionLabel = "ADMIN DROP"
bitLabel = "COLLECTIBLE"
}
var sparkles strings.Builder
h := sha256.Sum256([]byte(x.ID + "|card|" + x.Winner + "|" + x.Seed))
@@ -95,13 +101,13 @@ func renderCardSVG(art []byte, artExt string, x win, traits collectionTraits) []
<!-- Bottom collectible information panel -->
<g font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Arial,sans-serif" fill="#ffffff">
<text x="76" y="1234" font-size="15" font-weight="800" letter-spacing="5" opacity=".62">WINNING EDITION</text>
<text x="76" y="1234" font-size="15" font-weight="800" letter-spacing="5" opacity=".62">%s</text>
<text x="76" y="1290" font-size="42" font-weight="900" letter-spacing=".8">%s</text>
<line x1="76" y1="1320" x2="948" y2="1320" stroke="url(#holo)" stroke-width="2" opacity=".72"/>
<g transform="translate(76 1345)">
<rect width="176" height="50" rx="25" fill="#ffffff" fill-opacity=".10" stroke="#ffffff" stroke-opacity=".18"/>
<text x="88" y="32" text-anchor="middle" font-size="15" font-weight="800" letter-spacing="2">%d-BIT HUNT</text>
<text x="88" y="32" text-anchor="middle" font-size="15" font-weight="800" letter-spacing="2">%s</text>
</g>
<text x="76" y="1442" font-size="14" font-weight="700" letter-spacing="2" opacity=".56">%s · ORIGINAL FULL-ART SERIES</text>
@@ -110,7 +116,7 @@ func renderCardSVG(art []byte, artExt string, x win, traits collectionTraits) []
</svg>`,
traits.AccentA, traits.AccentB, traits.AccentA, traits.AccentB,
mime, encoded, sparkles.String(),
series, rarity, theme, x.RangeBits, completed, traits.AccentA, traits.AccentB,
series, rarity, editionLabel, theme, bitLabel, completed, traits.AccentA, traits.AccentB,
)
return []byte(svg)
}

View File

@@ -64,17 +64,18 @@ func envDuration(k string, d time.Duration) time.Duration {
}
type win struct {
ID, Seed, Winner, Worker, Signature, Guess string
DisplayName string
RangeBits int
Completed time.Time
PublicJWK json.RawMessage
PromptInstructions string
NegativePrompt string
StyleReference string
BeaconPath string
BeaconBoostedPath string
BeaconRound uint64
ID, Seed, Winner, ProvenanceWinner, Worker, Signature, Guess string
Origin string
DisplayName string
RangeBits int
Completed time.Time
PublicJWK json.RawMessage
PromptInstructions string
NegativePrompt string
StyleReference string
BeaconPath string
BeaconBoostedPath string
BeaconRound uint64
}
func (w *Worker) Run(ctx context.Context) {
@@ -101,9 +102,9 @@ func (w *Worker) claim(ctx context.Context) (win, error) {
var x win
var completedMS int64
var raw string
err = tx.QueryRowContext(ctx, `SELECT t.id,t.public_seed,t.winner_client_id,COALESCE(t.winner_worker_client_id,t.winner_client_id),t.winner_signature,t.winning_guess,t.display_name,t.range_bits,t.completed_at,c.public_jwk,t.nft_prompt_instructions,t.nft_negative_prompt,t.nft_style_reference,t.winner_beacon_path,t.winner_beacon_boosted_path,t.winner_beacon_round
FROM tasks t JOIN clients c ON c.id=COALESCE(t.winner_worker_client_id,t.winner_client_id)
WHERE t.artifact_status='pending' ORDER BY t.completed_at LIMIT 1`).Scan(&x.ID, &x.Seed, &x.Winner, &x.Worker, &x.Signature, &x.Guess, &x.DisplayName, &x.RangeBits, &completedMS, &raw, &x.PromptInstructions, &x.NegativePrompt, &x.StyleReference, &x.BeaconPath, &x.BeaconBoostedPath, &x.BeaconRound)
err = tx.QueryRowContext(ctx, `SELECT t.id,t.public_seed,COALESCE(t.artifact_owner_client_id,t.winner_client_id),COALESCE(t.winner_client_id,''),COALESCE(t.winner_worker_client_id,t.winner_client_id,t.artifact_owner_client_id),COALESCE(t.winner_signature,''),COALESCE(t.winning_guess,''),t.display_name,t.range_bits,t.completed_at,c.public_jwk,t.nft_prompt_instructions,t.nft_negative_prompt,t.nft_style_reference,t.winner_beacon_path,t.winner_beacon_boosted_path,t.winner_beacon_round,t.artifact_origin
FROM tasks t JOIN clients c ON c.id=COALESCE(t.winner_worker_client_id,t.winner_client_id,t.artifact_owner_client_id)
WHERE t.artifact_status='pending' AND COALESCE(t.artifact_owner_client_id,t.winner_client_id) IS NOT NULL ORDER BY t.completed_at LIMIT 1`).Scan(&x.ID, &x.Seed, &x.Winner, &x.ProvenanceWinner, &x.Worker, &x.Signature, &x.Guess, &x.DisplayName, &x.RangeBits, &completedMS, &raw, &x.PromptInstructions, &x.NegativePrompt, &x.StyleReference, &x.BeaconPath, &x.BeaconBoostedPath, &x.BeaconRound, &x.Origin)
if err != nil {
return win{}, err
}
@@ -214,7 +215,9 @@ func (w *Worker) one(ctx context.Context) error {
"task_id": x.ID,
"task_display_name": x.DisplayName,
"task_range_bits": x.RangeBits,
"winner_client_id": x.Winner,
"artifact_origin": x.Origin,
"artifact_owner_client_id": x.Winner,
"winner_client_id": x.ProvenanceWinner,
"winner_worker_client_id": x.Worker,
"winning_beacon_path": x.BeaconPath,
"winning_beacon_boosted_path": x.BeaconBoostedPath,
@@ -230,7 +233,7 @@ func (w *Worker) one(ctx context.Context) error {
"task_style_reference": x.StyleReference,
"provider": img.Provider,
"provider_meta": img.Meta,
"note": "winner_client_id is the reward owner; winner_worker_client_id and winning_worker_public_jwk authenticate the actual worker guess; image_sha256 binds the final card",
"note": "artifact_owner_client_id is current ownership at mint time; winner fields preserve game provenance when present; image_sha256 binds the final card",
}
if preset == collectionPresetRaccoon {
manifest["collection_character"] = "RIFT"

View File

@@ -3,6 +3,7 @@ package customer
import (
"context"
"crypto/subtle"
"database/sql"
"net/http"
"strconv"
"strings"
@@ -219,6 +220,26 @@ func (s *Service) adminUnblockCustomer(w http.ResponseWriter, r *http.Request) {
jsonOut(w, http.StatusOK, map[string]bool{"ok": true})
}
func (s *Service) adminSetCustomerWorkerLimit(w http.ResponseWriter, r *http.Request) {
cid := chi.URLParam(r, "id")
var in struct {
Bypass bool `json:"bypass"`
}
if decode(r, &in) != nil {
jsonOut(w, http.StatusBadRequest, map[string]string{"error": "bad json"})
return
}
if err := s.store.SetCustomerWorkerLimitBypass(r.Context(), cid, in.Bypass); err != nil {
if err == sql.ErrNoRows {
jsonOut(w, http.StatusNotFound, map[string]string{"error": "customer not found"})
return
}
jsonOut(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
jsonOut(w, http.StatusOK, map[string]any{"ok": true, "worker_limit_bypass": in.Bypass})
}
func (s *Service) adminStopCustomerWorkers(w http.ResponseWriter, r *http.Request) {
cid := chi.URLParam(r, "id")
if _, err := s.store.CustomerByID(r.Context(), cid); err != nil {

View File

@@ -257,6 +257,9 @@ func (s *Service) AdminRoutes(ui http.Handler) http.Handler {
r.Post("/api/admin/customers/{id}/block", s.adminBlockCustomer)
r.Post("/api/admin/customers/{id}/unblock", s.adminUnblockCustomer)
r.Post("/api/admin/customers/{id}/workers/stop", s.adminStopCustomerWorkers)
r.Put("/api/admin/customers/{id}/worker-limit", s.adminSetCustomerWorkerLimit)
r.Get("/api/admin/tasks", s.adminTaskCatalog)
r.Post("/api/admin/customers/{id}/workers", s.adminCreateWorkerForCustomer)
r.Post("/api/admin/workers/{id}/stop", s.adminStopAnyWorker)
r.Post("/api/admin/invites", s.adminCreateInvite)
})
@@ -515,49 +518,96 @@ func (s *Service) workers(w http.ResponseWriter, r *http.Request) {
}
jsonOut(w, 200, x)
}
func (s *Service) createWorkerRecord(ctx context.Context, cid, taskID, beaconPath string, ignoreCustomerLimit bool) (Worker, error) {
cust, err := s.store.CustomerByID(ctx, cid)
if err != nil {
return Worker{}, fmt.Errorf("customer not found")
}
if !ignoreCustomerLimit && !cust.WorkerLimitBypass {
n, err := s.store.WorkerCount(ctx, cid, false)
if err != nil {
return Worker{}, err
}
if n >= s.cfg.MaxWorkersPerCustomer {
return Worker{}, fmt.Errorf("worker limit reached (%d)", s.cfg.MaxWorkersPerCustomer)
}
}
n, err := s.store.TotalWorkerCount(ctx)
if err != nil {
return Worker{}, err
}
if n >= s.cfg.MaxWorkersGlobal {
return Worker{}, fmt.Errorf("global hosted worker inventory limit reached")
}
available, err := s.taskAvailable(ctx, taskID)
if err != nil {
return Worker{}, fmt.Errorf("task catalog: %w", err)
}
if !available {
return Worker{}, fmt.Errorf("task is not active/available")
}
wid := "wrk_" + RandomToken(12)
wk := Worker{ID: wid, CustomerID: cid, TaskID: strings.TrimSpace(taskID), BeaconPath: normalizePath(beaconPath), Volume: "nh_identity_" + strings.ReplaceAll(wid, "-", "_"), RegisterToken: RandomToken(24), RateMicrosPerMinute: s.cfg.WorkerRateMicrosPerMinute}
if err := s.store.CreateWorker(ctx, wk); err != nil {
return Worker{}, err
}
return wk, nil
}
func (s *Service) createWorker(w http.ResponseWriter, r *http.Request) {
cid := customerID(r)
if n, err := s.store.WorkerCount(r.Context(), cid, false); err != nil {
jsonOut(w, 500, map[string]string{"error": err.Error()})
return
} else if n >= s.cfg.MaxWorkersPerCustomer {
jsonOut(w, 409, map[string]string{"error": fmt.Sprintf("worker limit reached (%d)", s.cfg.MaxWorkersPerCustomer)})
return
}
if n, err := s.store.TotalWorkerCount(r.Context()); err != nil {
jsonOut(w, 500, map[string]string{"error": err.Error()})
return
} else if n >= s.cfg.MaxWorkersGlobal {
jsonOut(w, 503, map[string]string{"error": "global hosted worker inventory limit reached"})
return
}
var in struct{ TaskID, BeaconPath string }
if decode(r, &in) != nil || strings.TrimSpace(in.TaskID) == "" {
jsonOut(w, 400, map[string]string{"error": "task_id required"})
return
}
available, err := s.taskAvailable(r.Context(), in.TaskID)
wk, err := s.createWorkerRecord(r.Context(), customerID(r), in.TaskID, in.BeaconPath, false)
if err != nil {
jsonOut(w, 502, map[string]string{"error": "task catalog: " + err.Error()})
return
}
if !available {
jsonOut(w, 400, map[string]string{"error": "task is not active/available"})
return
}
wid := "wrk_" + RandomToken(12)
wk := Worker{ID: wid, CustomerID: cid, TaskID: strings.TrimSpace(in.TaskID), BeaconPath: normalizePath(in.BeaconPath), Volume: "nh_identity_" + strings.ReplaceAll(wid, "-", "_"), RegisterToken: RandomToken(24), RateMicrosPerMinute: s.cfg.WorkerRateMicrosPerMinute}
// Store the intended named-volume reference, but do not allocate Docker
// resources merely because an account created a stopped worker. Docker will
// create the named volume lazily when the worker is started or an identity is
// explicitly uploaded. This prevents free stopped-worker creation from
// consuming host volumes.
if err := s.store.CreateWorker(r.Context(), wk); err != nil {
jsonOut(w, 500, map[string]string{"error": err.Error()})
status := http.StatusBadRequest
if strings.Contains(err.Error(), "limit reached") {
status = http.StatusConflict
}
if strings.Contains(err.Error(), "global hosted") || strings.Contains(err.Error(), "task catalog") {
status = http.StatusServiceUnavailable
}
jsonOut(w, status, map[string]string{"error": err.Error()})
return
}
jsonOut(w, 201, wk)
}
func (s *Service) adminTaskCatalog(w http.ResponseWriter, r *http.Request) {
var out any
if err := s.proxyGET(r.Context(), "/api/public/tasks", &out); err != nil {
jsonOut(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
jsonOut(w, http.StatusOK, out)
}
func (s *Service) adminCreateWorkerForCustomer(w http.ResponseWriter, r *http.Request) {
cid := chi.URLParam(r, "id")
var in struct {
TaskID string `json:"task_id"`
BeaconPath string `json:"beacon_path"`
}
if decode(r, &in) != nil || strings.TrimSpace(in.TaskID) == "" {
jsonOut(w, http.StatusBadRequest, map[string]string{"error": "task_id required"})
return
}
wk, err := s.createWorkerRecord(r.Context(), cid, in.TaskID, in.BeaconPath, true)
if err != nil {
status := http.StatusBadRequest
if strings.Contains(err.Error(), "global hosted") {
status = http.StatusServiceUnavailable
}
if strings.Contains(err.Error(), "customer not found") {
status = http.StatusNotFound
}
jsonOut(w, status, map[string]string{"error": err.Error()})
return
}
jsonOut(w, http.StatusCreated, wk)
}
func (s *Service) updateWorker(w http.ResponseWriter, r *http.Request) {
cid, wid := customerID(r), chi.URLParam(r, "id")
wk, err := s.store.Worker(r.Context(), cid, wid)
@@ -650,10 +700,17 @@ func (s *Service) startWorker(w http.ResponseWriter, r *http.Request) {
return
}
if wk.Status != "running" {
if n, _ := s.store.WorkerCount(r.Context(), cid, true); n >= s.cfg.MaxRunningPerCustomer {
jsonOut(w, 409, map[string]string{"error": fmt.Sprintf("running worker limit reached (%d)", s.cfg.MaxRunningPerCustomer)})
cust, custErr := s.store.CustomerByID(r.Context(), cid)
if custErr != nil {
jsonOut(w, 404, map[string]string{"error": "customer not found"})
return
}
if !cust.WorkerLimitBypass {
if n, _ := s.store.WorkerCount(r.Context(), cid, true); n >= s.cfg.MaxRunningPerCustomer {
jsonOut(w, 409, map[string]string{"error": fmt.Sprintf("running worker limit reached (%d)", s.cfg.MaxRunningPerCustomer)})
return
}
}
if n, _ := s.store.RunningWorkerCount(r.Context()); n >= s.cfg.MaxRunningGlobal {
jsonOut(w, 503, map[string]string{"error": "hosted worker capacity reached"})
return
@@ -1348,7 +1405,7 @@ func (s *Service) adminLogout(w http.ResponseWriter, r *http.Request) {
jsonOut(w, 200, map[string]bool{"ok": true})
}
func (s *Service) adminOverview(w http.ResponseWriter, r *http.Request) {
rows, err := s.store.DB.QueryContext(r.Context(), `SELECT c.id,c.username,c.reward_client_id,c.blocked,c.blocked_reason,c.blocked_at,c.created_at,COALESCE((SELECT sum(delta_micros) FROM credit_ledger l WHERE l.customer_id=c.id),0) FROM customers c ORDER BY c.created_at DESC LIMIT 500`)
rows, err := s.store.DB.QueryContext(r.Context(), `SELECT c.id,c.username,c.reward_client_id,c.blocked,c.blocked_reason,c.blocked_at,c.worker_limit_bypass,c.created_at,COALESCE((SELECT sum(delta_micros) FROM credit_ledger l WHERE l.customer_id=c.id),0) FROM customers c ORDER BY c.created_at DESC LIMIT 500`)
if err != nil {
jsonOut(w, 500, map[string]string{"error": err.Error()})
return
@@ -1357,10 +1414,10 @@ func (s *Service) adminOverview(w http.ResponseWriter, r *http.Request) {
var out []map[string]any
for rows.Next() {
var id, user, reward, blockedReason string
var blocked int
var blocked, workerLimitBypass int
var blockedAt sql.NullInt64
var created, bal int64
if err := rows.Scan(&id, &user, &reward, &blocked, &blockedReason, &blockedAt, &created, &bal); err != nil {
if err := rows.Scan(&id, &user, &reward, &blocked, &blockedReason, &blockedAt, &workerLimitBypass, &created, &bal); err != nil {
continue
}
workers, _ := s.store.Workers(r.Context(), id)
@@ -1372,7 +1429,7 @@ func (s *Service) adminOverview(w http.ResponseWriter, r *http.Request) {
}
item := map[string]any{
"id": id, "username": user, "reward_client_id": reward,
"blocked": blocked != 0, "blocked_reason": blockedReason,
"blocked": blocked != 0, "blocked_reason": blockedReason, "worker_limit_bypass": workerLimitBypass != 0,
"created_at": time.UnixMilli(created).UTC(), "balance_micros": bal,
"workers": len(workers), "running": running, "worker_items": workers,
}

View File

@@ -25,6 +25,7 @@ CREATE TABLE IF NOT EXISTS customers(
blocked INTEGER NOT NULL DEFAULT 0,
blocked_reason TEXT NOT NULL DEFAULT '',
blocked_at INTEGER,
worker_limit_bypass INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
@@ -139,6 +140,7 @@ func Open(ctx context.Context, path string) (*Store, error) {
{"blocked", "INTEGER NOT NULL DEFAULT 0"},
{"blocked_reason", "TEXT NOT NULL DEFAULT ''"},
{"blocked_at", "INTEGER"},
{"worker_limit_bypass", "INTEGER NOT NULL DEFAULT 0"},
} {
if err := ensureCustomerColumn(ctx, db, "customers", m.name, m.def); err != nil {
db.Close()
@@ -171,13 +173,14 @@ func ensureCustomerColumn(ctx context.Context, db *sql.DB, table, name, def stri
}
type Customer struct {
ID string `json:"id"`
Username string `json:"username"`
RewardClientID string `json:"reward_client_id"`
Blocked bool `json:"blocked"`
BlockedReason string `json:"blocked_reason,omitempty"`
BlockedAt *time.Time `json:"blocked_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
ID string `json:"id"`
Username string `json:"username"`
RewardClientID string `json:"reward_client_id"`
Blocked bool `json:"blocked"`
BlockedReason string `json:"blocked_reason,omitempty"`
BlockedAt *time.Time `json:"blocked_at,omitempty"`
WorkerLimitBypass bool `json:"worker_limit_bypass"`
CreatedAt time.Time `json:"created_at"`
}
type Worker struct {
@@ -201,16 +204,17 @@ var ErrInvalidInvite = errors.New("registration invite invalid, expired or alrea
func scanCustomer(row interface{ Scan(...any) error }, withPassword bool) (Customer, string, string, error) {
var c Customer
var blocked int
var blocked, workerLimitBypass int
var blockedAt sql.NullInt64
var created int64
var salt, hash string
args := []any{&c.ID, &c.Username, &c.RewardClientID, &blocked, &c.BlockedReason, &blockedAt, &created}
args := []any{&c.ID, &c.Username, &c.RewardClientID, &blocked, &c.BlockedReason, &blockedAt, &workerLimitBypass, &created}
if withPassword {
args = append(args, &salt, &hash)
}
err := row.Scan(args...)
c.Blocked = blocked != 0
c.WorkerLimitBypass = workerLimitBypass != 0
c.CreatedAt = time.UnixMilli(created).UTC()
if blockedAt.Valid {
v := time.UnixMilli(blockedAt.Int64).UTC()
@@ -251,14 +255,14 @@ func (s *Store) CreateCustomer(ctx context.Context, id, username, salt, hash str
}
func (s *Store) CustomerByUsername(ctx context.Context, username string) (Customer, string, string, error) {
return scanCustomer(s.DB.QueryRowContext(ctx, `SELECT id,username,reward_client_id,blocked,blocked_reason,blocked_at,created_at,password_salt,password_hash FROM customers WHERE username=?`, strings.TrimSpace(username)), true)
return scanCustomer(s.DB.QueryRowContext(ctx, `SELECT id,username,reward_client_id,blocked,blocked_reason,blocked_at,worker_limit_bypass,created_at,password_salt,password_hash FROM customers WHERE username=?`, strings.TrimSpace(username)), true)
}
func (s *Store) CustomerByID(ctx context.Context, id string) (Customer, error) {
c, _, _, err := scanCustomer(s.DB.QueryRowContext(ctx, `SELECT id,username,reward_client_id,blocked,blocked_reason,blocked_at,created_at FROM customers WHERE id=?`, id), false)
c, _, _, err := scanCustomer(s.DB.QueryRowContext(ctx, `SELECT id,username,reward_client_id,blocked,blocked_reason,blocked_at,worker_limit_bypass,created_at FROM customers WHERE id=?`, id), false)
return c, err
}
func (s *Store) CustomerByRewardClientID(ctx context.Context, rewardClientID string) (Customer, error) {
c, _, _, err := scanCustomer(s.DB.QueryRowContext(ctx, `SELECT id,username,reward_client_id,blocked,blocked_reason,blocked_at,created_at FROM customers WHERE reward_client_id=? ORDER BY created_at LIMIT 1`, strings.TrimSpace(rewardClientID)), false)
c, _, _, err := scanCustomer(s.DB.QueryRowContext(ctx, `SELECT id,username,reward_client_id,blocked,blocked_reason,blocked_at,worker_limit_bypass,created_at FROM customers WHERE reward_client_id=? ORDER BY created_at LIMIT 1`, strings.TrimSpace(rewardClientID)), false)
return c, err
}
func (s *Store) SetRewardClientID(ctx context.Context, id, cid string) error {
@@ -274,6 +278,22 @@ func (s *Store) SetCustomerBlocked(ctx context.Context, id string, blocked bool,
_, err := s.DB.ExecContext(ctx, `UPDATE customers SET blocked=0,blocked_reason='',blocked_at=NULL,updated_at=? WHERE id=?`, now, id)
return err
}
func (s *Store) SetCustomerWorkerLimitBypass(ctx context.Context, id string, bypass bool) error {
v := 0
if bypass {
v = 1
}
res, err := s.DB.ExecContext(ctx, `UPDATE customers SET worker_limit_bypass=?,updated_at=? WHERE id=?`, v, time.Now().UTC().UnixMilli(), id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return sql.ErrNoRows
}
return nil
}
func (s *Store) DeleteCustomerSessions(ctx context.Context, cid string) error {
_, err := s.DB.ExecContext(ctx, `DELETE FROM customer_sessions WHERE customer_id=?`, cid)
return err

View File

@@ -1,4 +1,4 @@
const $=id=>document.getElementById(id);let manual=false,last=null;
const $=id=>document.getElementById(id);let manual=false,last=null,taskCatalog=[];
function esc(s){return String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))}
function msg(t,e=false){$('msg').textContent=t;$('msg').className='msg'+(e?' err':'');$('msg').classList.remove('hidden');setTimeout(()=>$('msg').classList.add('hidden'),7000)}
async function api(p,o={}){if(o.body&&typeof o.body!=='string'){o.headers={'Content-Type':'application/json',...(o.headers||{})};o.body=JSON.stringify(o.body)}const r=await fetch(p,{credentials:'same-origin',...o});const x=await r.json().catch(()=>({}));if(!r.ok){const e=new Error(x.error||`HTTP ${r.status}`);e.status=r.status;throw e}return x}
@@ -7,7 +7,8 @@ function settingsToUI(s){$('loginEnabled').checked=!!s.login_enabled;$('registra
function settingsPayload(){return{login_enabled:$('loginEnabled').checked,registration_enabled:$('registrationEnabled').checked,invite_required:$('inviteRequired').checked,registration_pow_bits:Number($('powBits').value||0),signup_bonus_credits:Number($('signupBonus').value||0),positive_tip_bonus_credits:Number($('positiveBonus').value||0)}}
async function saveSettings(){try{const x=await api('/api/admin/settings',{method:'PUT',body:settingsPayload()});settingsToUI(x);msg('Portal-Einstellungen gespeichert')}catch(e){msg(e.message,true)}}
function workerRows(c){if(!c.worker_items?.length)return'<div class="small">Keine Worker.</div>';return `<div class="worker-list">${c.worker_items.map(w=>`<div class="worker-row"><div><strong>${esc(w.id)}</strong><div class="small">${esc(w.worker_client_id||'noch keine Identity')} · Task ${esc(w.task_id)}</div></div><span class="pill ${w.status==='running'?'on':''}">${esc(w.status)}</span><button class="ghost stopWorker" data-wid="${esc(w.id)}" ${w.status==='stopped'?'disabled':''}>STOP</button></div>`).join('')}</div>`}
function customerHTML(c){const status=c.blocked?`<span class="pill danger">GESPERRT</span>`:'<span class="pill on">AKTIV</span>';return `<article class="customer ${c.blocked?'blocked':''}" data-id="${esc(c.id)}"><div class="customer-head"><div><h3>${esc(c.username)} ${status}</h3><div class="small">${esc(c.id)} · Reward ${esc(c.reward_client_id||'—')}</div>${c.blocked_reason?`<div class="small danger-text">Grund: ${esc(c.blocked_reason)}</div>`:''}</div><div class="customer-stats"><strong>${cr(c.balance_micros)} Credits</strong><span>${c.running}/${c.workers} Worker aktiv</span></div></div><div class="actions">${c.blocked?'<button class="unblock">FREIGEBEN</button>':'<button class="danger block">BENUTZER SPERREN</button>'}<button class="ghost stopAll" ${c.running?'' :'disabled'}>ALLE WORKER STOPPEN</button>${manual?'<input class="amount" type="number" min="0.001" step="1" placeholder="Credits"><button class="grantBtn">+ CREDIT</button>':''}</div>${workerRows(c)}</article>`}
function bindCustomerActions(){document.querySelectorAll('.block').forEach(b=>b.onclick=async()=>{const row=b.closest('.customer');const reason=prompt('Optionaler Sperrgrund:','');if(reason===null)return;if(!confirm('Benutzer sperren, Sessions invalidieren und alle Worker stoppen?'))return;try{const x=await api(`/api/admin/customers/${encodeURIComponent(row.dataset.id)}/block`,{method:'POST',body:{reason}});msg(x.warnings?.length?`Benutzer gesperrt · Warnungen: ${x.warnings.join(' · ')}`:'Benutzer gesperrt',!!x.warnings?.length);await load()}catch(e){msg(e.message,true)}});document.querySelectorAll('.unblock').forEach(b=>b.onclick=async()=>{const row=b.closest('.customer');try{await api(`/api/admin/customers/${encodeURIComponent(row.dataset.id)}/unblock`,{method:'POST'});msg('Benutzer freigegeben');await load()}catch(e){msg(e.message,true)}});document.querySelectorAll('.stopAll').forEach(b=>b.onclick=async()=>{const row=b.closest('.customer');if(!confirm('Alle Worker dieses Benutzers stoppen?'))return;try{const x=await api(`/api/admin/customers/${encodeURIComponent(row.dataset.id)}/workers/stop`,{method:'POST'});msg(x.warnings?.length?x.warnings.join(' · '):'Worker gestoppt',!!x.warnings?.length);await load()}catch(e){msg(e.message,true)}});document.querySelectorAll('.stopWorker').forEach(b=>b.onclick=async()=>{try{await api(`/api/admin/workers/${encodeURIComponent(b.dataset.wid)}/stop`,{method:'POST'});msg('Worker gestoppt');await load()}catch(e){msg(e.message,true)}});document.querySelectorAll('.grantBtn').forEach(b=>b.onclick=async()=>{const row=b.closest('.customer');const n=Number(row.querySelector('.amount').value);if(!n)return;try{await api('/api/admin/credits/grant',{method:'POST',body:{customer_id:row.dataset.id,credits:n,reason:'admin-ui'}});msg(`${n} Credits gebucht`);await load()}catch(e){msg(e.message,true)}})}
async function load(){const x=await api('/api/admin/overview');last=x;manual=x.manual_credits_enabled;$('loginBox').classList.add('hidden');$('panel').classList.remove('hidden');$('manual').textContent=manual?'MANUELLER CREDIT-BYPASS IST AKTIV. Jede Buchung bleibt im Ledger nachvollziehbar.':'Manuelle Credits sind deaktiviert (CS_ALLOW_MANUAL_CREDITS=0).';settingsToUI(x.settings||{});$('customers').innerHTML=(x.customers||[]).map(customerHTML).join('')||'<div class="notice">Noch keine Benutzer.</div>';bindCustomerActions()}
function taskOptions(){return taskCatalog.map(t=>`<option value="${esc(t.id)}">${esc(t.display_name||t.id.slice(-12))} · ${Number(t.range_bits||0)} Bit</option>`).join('')}
function customerHTML(c){const status=c.blocked?`<span class="pill danger">GESPERRT</span>`:'<span class="pill on">AKTIV</span>',bypass=c.worker_limit_bypass?'<span class="pill warn">LIMIT BYPASS</span>':'';return `<article class="customer ${c.blocked?'blocked':''}" data-id="${esc(c.id)}"><div class="customer-head"><div><h3>${esc(c.username)} ${status} ${bypass}</h3><div class="small">${esc(c.id)} · Reward ${esc(c.reward_client_id||'—')}</div>${c.blocked_reason?`<div class="small danger-text">Grund: ${esc(c.blocked_reason)}</div>`:''}</div><div class="customer-stats"><strong>${cr(c.balance_micros)} Credits</strong><span>${c.running}/${c.workers} Worker aktiv</span></div></div><div class="actions">${c.blocked?'<button class="unblock">FREIGEBEN</button>':'<button class="danger block">BENUTZER SPERREN</button>'}<button class="ghost stopAll" ${c.running?'' :'disabled'}>ALLE WORKER STOPPEN</button><button class="ghost limitBypass">${c.worker_limit_bypass?'LIMIT NORMAL':'LIMIT AUFHEBEN'}</button>${manual?'<input class="amount" type="number" min="0.001" step="1" placeholder="Credits"><button class="grantBtn">+ CREDIT</button>':''}</div><div class="admin-worker-create"><select class="newWorkerTask"><option value="">Task wählen …</option>${taskOptions()}</select><select class="newWorkerBeacon"><option value="auto">AUTO</option><option value="pulse">PULSE</option><option value="flux">FLUX</option><option value="orbit">ORBIT</option></select><button class="addWorker" ${taskCatalog.length?'':'disabled'}>WORKER +</button></div>${workerRows(c)}</article>`}
function bindCustomerActions(){document.querySelectorAll('.block').forEach(b=>b.onclick=async()=>{const row=b.closest('.customer');const reason=prompt('Optionaler Sperrgrund:','');if(reason===null)return;if(!confirm('Benutzer sperren, Sessions invalidieren und alle Worker stoppen?'))return;try{const x=await api(`/api/admin/customers/${encodeURIComponent(row.dataset.id)}/block`,{method:'POST',body:{reason}});msg(x.warnings?.length?`Benutzer gesperrt · Warnungen: ${x.warnings.join(' · ')}`:'Benutzer gesperrt',!!x.warnings?.length);await load()}catch(e){msg(e.message,true)}});document.querySelectorAll('.unblock').forEach(b=>b.onclick=async()=>{const row=b.closest('.customer');try{await api(`/api/admin/customers/${encodeURIComponent(row.dataset.id)}/unblock`,{method:'POST'});msg('Benutzer freigegeben');await load()}catch(e){msg(e.message,true)}});document.querySelectorAll('.stopAll').forEach(b=>b.onclick=async()=>{const row=b.closest('.customer');if(!confirm('Alle Worker dieses Benutzers stoppen?'))return;try{const x=await api(`/api/admin/customers/${encodeURIComponent(row.dataset.id)}/workers/stop`,{method:'POST'});msg(x.warnings?.length?x.warnings.join(' · '):'Worker gestoppt',!!x.warnings?.length);await load()}catch(e){msg(e.message,true)}});document.querySelectorAll('.stopWorker').forEach(b=>b.onclick=async()=>{try{await api(`/api/admin/workers/${encodeURIComponent(b.dataset.wid)}/stop`,{method:'POST'});msg('Worker gestoppt');await load()}catch(e){msg(e.message,true)}});document.querySelectorAll('.limitBypass').forEach(b=>b.onclick=async()=>{const row=b.closest('.customer'),c=last?.customers?.find(x=>x.id===row.dataset.id);try{await api(`/api/admin/customers/${encodeURIComponent(row.dataset.id)}/worker-limit`,{method:'PUT',body:{bypass:!c?.worker_limit_bypass}});msg(c?.worker_limit_bypass?'Worker-Limit wieder aktiv':'Worker-Limit für Benutzer aufgehoben');await load()}catch(e){msg(e.message,true)}});document.querySelectorAll('.addWorker').forEach(b=>b.onclick=async()=>{const row=b.closest('.customer'),task=row.querySelector('.newWorkerTask').value,beacon=row.querySelector('.newWorkerBeacon').value;if(!task){msg('Task auswählen',true);return}try{await api(`/api/admin/customers/${encodeURIComponent(row.dataset.id)}/workers`,{method:'POST',body:{task_id:task,beacon_path:beacon}});msg('Worker dem Benutzer zugeordnet');await load()}catch(e){msg(e.message,true)}});document.querySelectorAll('.grantBtn').forEach(b=>b.onclick=async()=>{const row=b.closest('.customer');const n=Number(row.querySelector('.amount').value);if(!n)return;try{await api('/api/admin/credits/grant',{method:'POST',body:{customer_id:row.dataset.id,credits:n,reason:'admin-ui'}});msg(`${n} Credits gebucht`);await load()}catch(e){msg(e.message,true)}})}
async function load(){const [x,tasks]=await Promise.all([api('/api/admin/overview'),api('/api/admin/tasks').catch(()=>[])]);last=x;taskCatalog=Array.isArray(tasks)?tasks:[];manual=x.manual_credits_enabled;$('loginBox').classList.add('hidden');$('panel').classList.remove('hidden');$('manual').textContent=manual?'MANUELLER CREDIT-BYPASS IST AKTIV. Jede Buchung bleibt im Ledger nachvollziehbar.':'Manuelle Credits sind deaktiviert (CS_ALLOW_MANUAL_CREDITS=0).';settingsToUI(x.settings||{});$('customers').innerHTML=(x.customers||[]).map(customerHTML).join('')||'<div class="notice">Noch keine Benutzer.</div>';bindCustomerActions()}
$('saveSettings').onclick=saveSettings;$('saveRewards').onclick=saveSettings;$('createInvite').onclick=async()=>{try{const x=await api('/api/admin/invites',{method:'POST',body:{label:$('inviteLabel').value,hours:Number($('inviteHours').value||24)}});$('inviteOut').textContent=x.invite_code;$('inviteOut').classList.remove('hidden');msg('Invite erzeugt · jetzt sicher an genau einen Benutzer weitergeben');await load()}catch(e){msg(e.message,true)}};$('refresh').onclick=()=>load().catch(e=>msg(e.message,true));$('login').onclick=async()=>{try{await api('/api/admin/login',{method:'POST',body:{Username:$('user').value,Password:$('pass').value}});await load()}catch(e){msg(e.message,true)}};$('logout').onclick=async()=>{await api('/api/admin/logout',{method:'POST'}).catch(()=>{});location.reload()};load().catch(()=>{});

View File

@@ -1 +1,2 @@
:root{color-scheme:dark;--bg:#080b10;--card:#111821;--line:#293746;--text:#edf4fb;--muted:#8fa1b2;--accent:#54f0a6;--danger:#ff6978;--warn:#ffca56}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:14px/1.45 ui-monospace,monospace}main{max-width:1200px;margin:auto;padding:34px 20px}.eyebrow{color:var(--accent);letter-spacing:.2em}h1{font:700 38px system-ui}h2,h3{font-family:system-ui;margin:0 0 10px}section,.customer,article{border:1px solid var(--line);background:var(--card);border-radius:12px;padding:16px;margin:12px 0}label{display:block;color:var(--muted);margin:10px 0}label.toggle{display:flex;align-items:center;gap:9px;color:var(--text)}label.toggle input{width:auto;margin:0}input{display:block;width:100%;margin-top:5px;background:#090e14;color:var(--text);border:1px solid var(--line);border-radius:8px;padding:10px}button{background:var(--accent);color:#06120c;border:0;border-radius:8px;padding:9px 12px;font-weight:900;cursor:pointer}button:disabled{opacity:.4;cursor:not-allowed}.ghost{background:#1a2530;color:var(--text);border:1px solid var(--line)}button.danger{background:#4b1d24;color:#ffd7dc;border:1px solid #79343f}.hidden{display:none!important}.head,.section-title,.customer-head,.actions,.worker-row{display:flex;justify-content:space-between;gap:12px;align-items:center}.admin-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}.admin-grid article{margin:0}.small{font-size:12px;color:var(--muted);word-break:break-all}.notice,.msg,.invite{padding:10px;border-radius:8px;background:#18261f;margin:10px 0}.msg.err{background:#32171d}.invite{border:1px solid var(--accent);font-weight:800;word-break:break-all;user-select:all}.customer{margin:12px 0}.customer.blocked{border-color:#6b333b}.customer h3{display:flex;align-items:center;gap:8px}.customer-stats{display:flex;flex-direction:column;align-items:flex-end;color:var(--muted)}.actions{justify-content:flex-start;flex-wrap:wrap;margin:14px 0}.actions input{width:130px;margin:0}.worker-list{display:grid;gap:6px;border-top:1px solid var(--line);padding-top:10px}.worker-row{background:#0b1118;border-radius:8px;padding:8px 10px}.pill{font-size:11px;padding:3px 7px;border-radius:99px;background:#222d38;color:var(--muted)}.pill.on{background:#123022;color:var(--accent)}.pill.danger{background:#3c171d;color:#ff9ba6}.danger-text{color:#ff9ba6}@media(max-width:900px){.admin-grid{grid-template-columns:1fr}.customer-head,.head,.section-title{align-items:flex-start;flex-direction:column}.customer-stats{align-items:flex-start}.worker-row{align-items:flex-start;flex-wrap:wrap}}
.admin-worker-create{display:grid;grid-template-columns:minmax(0,1fr) 110px auto;gap:8px;margin:10px 0 12px}.admin-worker-create select{min-width:0}.pill.warn{color:#ffd781;border-color:rgba(255,205,90,.28);background:rgba(255,205,90,.07)}@media(max-width:720px){.admin-worker-create{grid-template-columns:1fr 1fr}.admin-worker-create .addWorker{grid-column:1/-1}}

View File

@@ -0,0 +1,117 @@
package data
import (
"context"
"errors"
"testing"
"time"
"neuralhunt/internal/auth"
)
func TestRetiringTaskDoesNotCreateInheritedSuccessor(t *testing.T) {
ctx := context.Background()
db, err := OpenSQLite(ctx, t.TempDir()+"/retire.db")
if err != nil {
t.Fatal(err)
}
defer db.Close()
s := New(db)
if err := s.EnsureActiveTasks(ctx, 1, 28); err != nil {
t.Fatal(err)
}
ts, err := s.ActiveTasks(ctx)
if err != nil || len(ts) != 1 {
t.Fatalf("active tasks len=%d err=%v", len(ts), err)
}
pred := ts[0]
if err := s.UpdateTaskConfig(ctx, pred.ID, "Limited Series", "", "", ""); err != nil {
t.Fatal(err)
}
if err := s.SetTaskRetireAfterCompletion(ctx, pred.ID, true); err != nil {
t.Fatal(err)
}
if err := s.CloseTask(ctx, pred.ID); err != nil {
t.Fatal(err)
}
if _, err := s.EnsureSuccessorTask(ctx, pred.ID, 28); !errors.Is(err, ErrTaskRetiring) {
t.Fatalf("expected ErrTaskRetiring, got %v", err)
}
var children int
if err := db.QueryRowContext(ctx, `SELECT count(*) FROM tasks WHERE parent_task_id=?`, pred.ID).Scan(&children); err != nil {
t.Fatal(err)
}
if children != 0 {
t.Fatalf("retiring task got %d inherited successors", children)
}
if err := s.EnsureActiveTasks(ctx, 1, 28); err != nil {
t.Fatal(err)
}
active, err := s.ActiveTasks(ctx)
if err != nil || len(active) != 1 {
t.Fatalf("replacement active tasks len=%d err=%v", len(active), err)
}
if active[0].ParentTaskID != nil {
t.Fatalf("replacement should be a fresh root task, parent=%v", *active[0].ParentTaskID)
}
}
func TestArtifactTransferPreservesWinnerAndAdminDropHasNoGameWin(t *testing.T) {
ctx := context.Background()
db, err := OpenSQLite(ctx, t.TempDir()+"/ownership.db")
if err != nil {
t.Fatal(err)
}
defer db.Close()
s := New(db)
jwk := auth.PublicJWK{Kty: "EC", Crv: "P-256", X: "AQ", Y: "Ag"}
if err := s.UpsertClient(ctx, "client_a", jwk); err != nil {
t.Fatal(err)
}
if err := s.UpsertClient(ctx, "client_b", jwk); err != nil {
t.Fatal(err)
}
if err := s.EnsureActiveTasks(ctx, 1, 28); err != nil {
t.Fatal(err)
}
ts, _ := s.ActiveTasks(ctx)
id := ts[0].ID
if _, err := db.ExecContext(ctx, `UPDATE tasks SET status='completed',completed_at=?,winner_client_id='client_a',artifact_owner_client_id='client_a',artifact_origin='win',artifact_status='ready',artifact_uri='/artifacts/a/image.svg' WHERE id=?`, time.Now().UTC().UnixMilli(), id); err != nil {
t.Fatal(err)
}
from, err := s.TransferArtifact(ctx, id, "client_b", "admin transfer test")
if err != nil {
t.Fatal(err)
}
if from != "client_a" {
t.Fatalf("from=%q", from)
}
var winner, owner string
if err := db.QueryRowContext(ctx, `SELECT winner_client_id,artifact_owner_client_id FROM tasks WHERE id=?`, id).Scan(&winner, &owner); err != nil {
t.Fatal(err)
}
if winner != "client_a" || owner != "client_b" {
t.Fatalf("winner=%q owner=%q", winner, owner)
}
var transfers int
if err := db.QueryRowContext(ctx, `SELECT count(*) FROM artifact_transfers WHERE task_id=? AND from_client_id='client_a' AND to_client_id='client_b'`, id).Scan(&transfers); err != nil {
t.Fatal(err)
}
if transfers != 1 {
t.Fatalf("transfer audit count=%d", transfers)
}
drop, err := s.CreateAdminArtifactDrop(ctx, "client_b", id, 28)
if err != nil {
t.Fatal(err)
}
if drop.Status != "completed" || drop.ArtifactStatus != "pending" || drop.ArtifactOrigin != "admin_drop" || !drop.RetireAfterCompletion {
t.Fatalf("unexpected admin drop: %+v", drop)
}
if drop.WinnerClientID != nil {
t.Fatalf("admin drop must not create a historical game winner: %+v", drop.WinnerClientID)
}
if drop.ArtifactOwnerClientID == nil || *drop.ArtifactOwnerClientID != "client_b" {
t.Fatalf("admin drop owner=%v", drop.ArtifactOwnerClientID)
}
}

View File

@@ -29,6 +29,9 @@ CREATE TABLE IF NOT EXISTS tasks (
nft_prompt_instructions TEXT NOT NULL DEFAULT '',
nft_negative_prompt TEXT NOT NULL DEFAULT '',
nft_style_reference TEXT NOT NULL DEFAULT '',
retire_after_completion INTEGER NOT NULL DEFAULT 0,
artifact_owner_client_id TEXT REFERENCES clients(id),
artifact_origin TEXT NOT NULL DEFAULT 'win',
created_at INTEGER NOT NULL,
completed_at INTEGER,
winner_client_id TEXT REFERENCES clients(id),
@@ -45,6 +48,19 @@ CREATE TABLE IF NOT EXISTS tasks (
);
CREATE INDEX IF NOT EXISTS tasks_status_created_idx ON tasks(status, created_at);
-- Current collectible ownership is separate from historical winner provenance.
-- Transfers keep the original winner fields untouched and are audit logged here.
CREATE TABLE IF NOT EXISTS artifact_transfers (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
from_client_id TEXT REFERENCES clients(id) ON DELETE SET NULL,
to_client_id TEXT NOT NULL REFERENCES clients(id),
reason TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS artifact_transfers_task_idx ON artifact_transfers(task_id, created_at DESC);
CREATE INDEX IF NOT EXISTS artifact_transfers_to_idx ON artifact_transfers(to_client_id, created_at DESC);
-- False guesses are intentionally not persisted. This table only keeps the
-- aggregate state needed for the 3D map, ranking, rate limiting and next seq.
CREATE TABLE IF NOT EXISTS task_points (

View File

@@ -32,6 +32,7 @@ var (
ErrTaskCompleted = errors.New("task already completed")
ErrTaskPaused = errors.New("task paused")
ErrPresenceBusy = errors.New("identity already connected")
ErrTaskRetiring = errors.New("task is retiring; no inherited successor")
)
type Store struct{ DB *sql.DB }
@@ -113,6 +114,9 @@ func OpenSQLite(ctx context.Context, path string) (*sql.DB, error) {
{"winner_beacon_path", "TEXT NOT NULL DEFAULT ''"},
{"winner_beacon_boosted_path", "TEXT NOT NULL DEFAULT ''"},
{"winner_beacon_round", "INTEGER NOT NULL DEFAULT 0"},
{"retire_after_completion", "INTEGER NOT NULL DEFAULT 0"},
{"artifact_owner_client_id", "TEXT REFERENCES clients(id)"},
{"artifact_origin", "TEXT NOT NULL DEFAULT 'win'"},
} {
if err := ensureColumn(ctx, db, "tasks", m.name, m.def); err != nil {
db.Close()
@@ -221,6 +225,9 @@ type Task struct {
NFTPromptInstructions string
NFTNegativePrompt string
NFTStyleReference string
RetireAfterCompletion bool
ArtifactOwnerClientID *string
ArtifactOrigin string
CreatedAt time.Time
CompletedAt *time.Time
WinnerClientID *string
@@ -230,17 +237,17 @@ type Task struct {
ArtifactManifestURI *string
}
const taskColumns = `id,public_seed,range_bits,status,paused,guess_min_interval_sec,client_submit_interval_sec,revision,parent_task_id,display_name,description,nft_prompt_instructions,nft_negative_prompt,nft_style_reference,created_at,completed_at,winner_client_id,winner_worker_client_id,artifact_status,artifact_uri,artifact_manifest_uri`
const taskColumns = `id,public_seed,range_bits,status,paused,guess_min_interval_sec,client_submit_interval_sec,revision,parent_task_id,display_name,description,nft_prompt_instructions,nft_negative_prompt,nft_style_reference,retire_after_completion,artifact_owner_client_id,artifact_origin,created_at,completed_at,winner_client_id,winner_worker_client_id,artifact_status,artifact_uri,artifact_manifest_uri`
func scanTask(scanner interface{ Scan(...any) error }, withSecret bool) (Task, string, error) {
var t Task
var created int64
var completed sql.NullInt64
var winner, winnerWorker, artifactURI, manifestURI, parent sql.NullString
var winner, winnerWorker, artifactOwner, artifactURI, manifestURI, parent sql.NullString
var guessMin, clientSubmit sql.NullInt64
var paused int
var paused, retireAfter int
var secret string
args := []any{&t.ID, &t.PublicSeed, &t.RangeBits, &t.Status, &paused, &guessMin, &clientSubmit, &t.Revision, &parent, &t.DisplayName, &t.Description, &t.NFTPromptInstructions, &t.NFTNegativePrompt, &t.NFTStyleReference, &created, &completed, &winner, &winnerWorker, &t.ArtifactStatus, &artifactURI, &manifestURI}
args := []any{&t.ID, &t.PublicSeed, &t.RangeBits, &t.Status, &paused, &guessMin, &clientSubmit, &t.Revision, &parent, &t.DisplayName, &t.Description, &t.NFTPromptInstructions, &t.NFTNegativePrompt, &t.NFTStyleReference, &retireAfter, &artifactOwner, &t.ArtifactOrigin, &created, &completed, &winner, &winnerWorker, &t.ArtifactStatus, &artifactURI, &manifestURI}
if withSecret {
args = append(args, &secret)
}
@@ -248,6 +255,7 @@ func scanTask(scanner interface{ Scan(...any) error }, withSecret bool) (Task, s
return Task{}, "", err
}
t.Paused = paused != 0
t.RetireAfterCompletion = retireAfter != 0
if parent.Valid {
v := parent.String
t.ParentTaskID = &v
@@ -273,6 +281,10 @@ func scanTask(scanner interface{ Scan(...any) error }, withSecret bool) (Task, s
v := winnerWorker.String
t.WinnerWorkerClientID = &v
}
if artifactOwner.Valid {
v := artifactOwner.String
t.ArtifactOwnerClientID = &v
}
if artifactURI.Valid {
v := artifactURI.String
t.ArtifactURI = &v
@@ -371,6 +383,9 @@ func (s *Store) EnsureSuccessorTask(ctx context.Context, predecessorID string, f
if pred.Status == "active" {
return Task{}, fmt.Errorf("predecessor is still active")
}
if pred.RetireAfterCompletion {
return Task{}, ErrTaskRetiring
}
bits := pred.RangeBits
if bits < 8 || bits > 128 {
bits = fallbackBits
@@ -410,7 +425,7 @@ func (s *Store) EnsureActiveTasks(ctx context.Context, count, bits int) error {
// creating a generic task from global defaults.
var predID string
err := s.DB.QueryRowContext(ctx, `SELECT t.id FROM tasks t
WHERE t.status IN ('completed','closed') AND NOT EXISTS(SELECT 1 FROM tasks c WHERE c.parent_task_id=t.id)
WHERE t.status IN ('completed','closed') AND t.retire_after_completion=0 AND NOT EXISTS(SELECT 1 FROM tasks c WHERE c.parent_task_id=t.id)
ORDER BY COALESCE(t.completed_at,t.created_at) DESC,t.id DESC LIMIT 1`).Scan(&predID)
if err == nil {
if _, err := s.EnsureSuccessorTask(ctx, predID, bits); err != nil {
@@ -658,7 +673,7 @@ func (s *Store) SubmitGuess(ctx context.Context, t SecretTask, cid string, seq i
}
if correct {
res, err := tx.ExecContext(ctx, `UPDATE tasks SET status='completed',completed_at=?,winner_client_id=?,winner_signature=?,winning_guess=?,artifact_status='pending',revision=revision+1 WHERE id=? AND status='active'`, nowMS, cid, sig, guess, t.ID)
res, err := tx.ExecContext(ctx, `UPDATE tasks SET status='completed',completed_at=?,winner_client_id=?,artifact_owner_client_id=?,artifact_origin='win',winner_signature=?,winning_guess=?,artifact_status='pending',revision=revision+1 WHERE id=? AND status='active'`, nowMS, cid, cid, sig, guess, t.ID)
if err != nil {
return Point{}, err
}
@@ -787,8 +802,8 @@ func (s *Store) leaderboard(ctx context.Context, limit int, live bool) ([]Leader
COALESCE((SELECT max(p.score) FROM task_points p JOIN tasks t ON t.id=p.task_id WHERE p.client_id=c.id AND t.status='active'),0) AS live_score,
COALESCE((SELECT sum(p.guess_count) FROM task_points p WHERE p.client_id=c.id),0) AS guess_count,
EXISTS(SELECT 1 FROM presence_leases pl WHERE pl.client_id=c.id AND pl.expires_at>?) AS connected,
(SELECT count(*) FROM tasks a WHERE a.winner_client_id=c.id AND a.status='completed' AND a.artifact_status='ready' AND a.artifact_uri IS NOT NULL) AS nft_count,
(SELECT a.id FROM tasks a WHERE a.winner_client_id=c.id AND a.status='completed' AND a.artifact_status='ready' AND a.artifact_uri IS NOT NULL ORDER BY COALESCE(a.completed_at,a.created_at) DESC LIMIT 1) AS nft_task_id
(SELECT count(*) FROM tasks a WHERE COALESCE(a.artifact_owner_client_id,a.winner_client_id)=c.id AND a.status='completed' AND a.artifact_status='ready' AND a.artifact_uri IS NOT NULL) AS nft_count,
(SELECT a.id FROM tasks a WHERE COALESCE(a.artifact_owner_client_id,a.winner_client_id)=c.id AND a.status='completed' AND a.artifact_status='ready' AND a.artifact_uri IS NOT NULL ORDER BY COALESCE(a.completed_at,a.created_at) DESC LIMIT 1) AS nft_task_id
FROM clients c ORDER BY ` + order + ` LIMIT ?`
rows, err := s.DB.QueryContext(ctx, q, now, limit)
if err != nil {
@@ -841,6 +856,7 @@ type PublicArtifact struct {
TaskID string `json:"task_id"`
WinnerClientID string `json:"winner_client_id"`
RangeBits int `json:"range_bits"`
ArtifactOrigin string `json:"artifact_origin"`
CompletedAt time.Time `json:"completed_at"`
PreviewURI string `json:"preview_uri"`
}
@@ -850,10 +866,11 @@ func (s *Store) PublicArtifacts(ctx context.Context, limit int, winner string) (
limit = 48
}
winner = strings.TrimSpace(winner)
rows, err := s.DB.QueryContext(ctx, `SELECT id,winner_client_id,range_bits,COALESCE(completed_at,created_at)
rows, err := s.DB.QueryContext(ctx, `SELECT id,COALESCE(artifact_owner_client_id,winner_client_id),range_bits,artifact_origin,COALESCE(completed_at,created_at)
FROM tasks
WHERE status='completed' AND artifact_status='ready' AND artifact_uri IS NOT NULL
AND (?='' OR winner_client_id=?)
AND COALESCE(artifact_owner_client_id,winner_client_id) IS NOT NULL
AND (?='' OR COALESCE(artifact_owner_client_id,winner_client_id)=?)
ORDER BY COALESCE(completed_at,created_at) DESC LIMIT ?`, winner, winner, limit)
if err != nil {
return nil, err
@@ -863,7 +880,7 @@ func (s *Store) PublicArtifacts(ctx context.Context, limit int, winner string) (
for rows.Next() {
var a PublicArtifact
var completed int64
if err := rows.Scan(&a.TaskID, &a.WinnerClientID, &a.RangeBits, &completed); err != nil {
if err := rows.Scan(&a.TaskID, &a.WinnerClientID, &a.RangeBits, &a.ArtifactOrigin, &completed); err != nil {
return nil, err
}
a.CompletedAt = fromUnixMS(completed)
@@ -876,12 +893,13 @@ func (s *Store) PublicArtifacts(ctx context.Context, limit int, winner string) (
// OwnedArtifact is returned only to the authenticated winner. Unlike the
// public gallery it includes a private download URL for the original artifact.
type OwnedArtifact struct {
TaskID string `json:"task_id"`
DisplayName string `json:"display_name"`
RangeBits int `json:"range_bits"`
CompletedAt time.Time `json:"completed_at"`
PreviewURI string `json:"preview_uri"`
DownloadURI string `json:"download_uri"`
TaskID string `json:"task_id"`
DisplayName string `json:"display_name"`
RangeBits int `json:"range_bits"`
ArtifactOrigin string `json:"artifact_origin"`
CompletedAt time.Time `json:"completed_at"`
PreviewURI string `json:"preview_uri"`
DownloadURI string `json:"download_uri"`
}
func (s *Store) OwnedArtifacts(ctx context.Context, cid string, limit int) ([]OwnedArtifact, error) {
@@ -889,9 +907,9 @@ func (s *Store) OwnedArtifacts(ctx context.Context, cid string, limit int) ([]Ow
limit = 48
}
cid = strings.TrimSpace(cid)
rows, err := s.DB.QueryContext(ctx, `SELECT id,COALESCE(display_name,''),range_bits,COALESCE(completed_at,created_at)
rows, err := s.DB.QueryContext(ctx, `SELECT id,COALESCE(display_name,''),range_bits,artifact_origin,COALESCE(completed_at,created_at)
FROM tasks
WHERE status='completed' AND artifact_status='ready' AND artifact_uri IS NOT NULL AND winner_client_id=?
WHERE status='completed' AND artifact_status='ready' AND artifact_uri IS NOT NULL AND COALESCE(artifact_owner_client_id,winner_client_id)=?
ORDER BY COALESCE(completed_at,created_at) DESC LIMIT ?`, cid, limit)
if err != nil {
return nil, err
@@ -901,7 +919,7 @@ func (s *Store) OwnedArtifacts(ctx context.Context, cid string, limit int) ([]Ow
for rows.Next() {
var a OwnedArtifact
var completed int64
if err := rows.Scan(&a.TaskID, &a.DisplayName, &a.RangeBits, &completed); err != nil {
if err := rows.Scan(&a.TaskID, &a.DisplayName, &a.RangeBits, &a.ArtifactOrigin, &completed); err != nil {
return nil, err
}
a.CompletedAt = fromUnixMS(completed)
@@ -919,7 +937,7 @@ func (s *Store) OwnedArtifacts(ctx context.Context, cid string, limit int) ([]Ow
// insecure direct object reference.
func (s *Store) OwnedArtifactSource(ctx context.Context, taskID, cid string) (artifactURI string, ok bool, err error) {
err = s.DB.QueryRowContext(ctx, `SELECT artifact_uri FROM tasks
WHERE id=? AND winner_client_id=? AND status='completed' AND artifact_status='ready' AND artifact_uri IS NOT NULL`, taskID, cid).Scan(&artifactURI)
WHERE id=? AND COALESCE(artifact_owner_client_id,winner_client_id)=? AND status='completed' AND artifact_status='ready' AND artifact_uri IS NOT NULL`, taskID, cid).Scan(&artifactURI)
if errors.Is(err, sql.ErrNoRows) {
return "", false, nil
}
@@ -930,8 +948,8 @@ func (s *Store) OwnedArtifactSource(ctx context.Context, taskID, cid string) (ar
}
func (s *Store) PublicArtifactSource(ctx context.Context, taskID string) (artifactURI, winner string, ok bool, err error) {
err = s.DB.QueryRowContext(ctx, `SELECT artifact_uri,winner_client_id FROM tasks
WHERE id=? AND status='completed' AND artifact_status='ready' AND artifact_uri IS NOT NULL AND winner_client_id IS NOT NULL`, taskID).Scan(&artifactURI, &winner)
err = s.DB.QueryRowContext(ctx, `SELECT artifact_uri,COALESCE(artifact_owner_client_id,winner_client_id) FROM tasks
WHERE id=? AND status='completed' AND artifact_status='ready' AND artifact_uri IS NOT NULL AND COALESCE(artifact_owner_client_id,winner_client_id) IS NOT NULL`, taskID).Scan(&artifactURI, &winner)
if errors.Is(err, sql.ErrNoRows) {
return "", "", false, nil
}
@@ -970,6 +988,9 @@ type AdminTask struct {
NFTPromptInstructions string `json:"nft_prompt_instructions"`
NFTNegativePrompt string `json:"nft_negative_prompt"`
NFTStyleReference string `json:"nft_style_reference"`
RetireAfterCompletion bool `json:"retire_after_completion"`
ArtifactOwnerClientID *string `json:"artifact_owner_client_id,omitempty"`
ArtifactOrigin string `json:"artifact_origin"`
CreatedAt time.Time `json:"created_at"`
CompletedAt *time.Time `json:"completed_at"`
WinnerClientID *string `json:"winner_client_id"`
@@ -985,13 +1006,13 @@ func (s *Store) AdminTasks(ctx context.Context, status, query string, limit int)
if limit < 1 || limit > 1000 {
limit = 200
}
rows, err := s.DB.QueryContext(ctx, `SELECT t.id,t.status,t.paused,t.range_bits,t.guess_min_interval_sec,t.client_submit_interval_sec,t.revision,t.parent_task_id,t.display_name,t.description,t.nft_prompt_instructions,t.nft_negative_prompt,t.nft_style_reference,t.created_at,t.completed_at,t.winner_client_id,
rows, err := s.DB.QueryContext(ctx, `SELECT t.id,t.status,t.paused,t.range_bits,t.guess_min_interval_sec,t.client_submit_interval_sec,t.revision,t.parent_task_id,t.display_name,t.description,t.nft_prompt_instructions,t.nft_negative_prompt,t.nft_style_reference,t.retire_after_completion,t.artifact_owner_client_id,t.artifact_origin,t.created_at,t.completed_at,t.winner_client_id,
(SELECT count(*) FROM task_points p WHERE p.task_id=t.id),
COALESCE((SELECT sum(p.guess_count) FROM task_points p WHERE p.task_id=t.id),0),
t.artifact_status,t.artifact_uri,t.artifact_manifest_uri,t.artifact_error
FROM tasks t
WHERE (?='' OR t.status=?) AND (?='' OR lower(t.id) LIKE '%'||lower(?)||'%' OR lower(COALESCE(t.winner_client_id,'')) LIKE '%'||lower(?)||'%' OR lower(COALESCE(t.display_name,'')) LIKE '%'||lower(?)||'%')
ORDER BY t.created_at DESC LIMIT ?`, status, status, query, query, query, query, limit)
WHERE (?='' OR t.status=?) AND (?='' OR lower(t.id) LIKE '%'||lower(?)||'%' OR lower(COALESCE(t.winner_client_id,'')) LIKE '%'||lower(?)||'%' OR lower(COALESCE(t.artifact_owner_client_id,'')) LIKE '%'||lower(?)||'%' OR lower(COALESCE(t.display_name,'')) LIKE '%'||lower(?)||'%')
ORDER BY t.created_at DESC LIMIT ?`, status, status, query, query, query, query, query, limit)
if err != nil {
return nil, err
}
@@ -999,19 +1020,24 @@ func (s *Store) AdminTasks(ctx context.Context, status, query string, limit int)
out := make([]AdminTask, 0)
for rows.Next() {
var t AdminTask
var paused int
var paused, retire int
var created int64
var completed sql.NullInt64
var winner, artifactURI, manifestURI, artifactErr, parent sql.NullString
var winner, owner, artifactURI, manifestURI, artifactErr, parent sql.NullString
var guessMin, clientSubmit sql.NullInt64
if err := rows.Scan(&t.ID, &t.Status, &paused, &t.RangeBits, &guessMin, &clientSubmit, &t.Revision, &parent, &t.DisplayName, &t.Description, &t.NFTPromptInstructions, &t.NFTNegativePrompt, &t.NFTStyleReference, &created, &completed, &winner, &t.PointCount, &t.GuessCount, &t.ArtifactStatus, &artifactURI, &manifestURI, &artifactErr); err != nil {
if err := rows.Scan(&t.ID, &t.Status, &paused, &t.RangeBits, &guessMin, &clientSubmit, &t.Revision, &parent, &t.DisplayName, &t.Description, &t.NFTPromptInstructions, &t.NFTNegativePrompt, &t.NFTStyleReference, &retire, &owner, &t.ArtifactOrigin, &created, &completed, &winner, &t.PointCount, &t.GuessCount, &t.ArtifactStatus, &artifactURI, &manifestURI, &artifactErr); err != nil {
return nil, err
}
t.Paused = paused != 0
t.RetireAfterCompletion = retire != 0
if parent.Valid {
v := parent.String
t.ParentTaskID = &v
}
if owner.Valid {
v := owner.String
t.ArtifactOwnerClientID = &v
}
if guessMin.Valid {
v := int(guessMin.Int64)
t.GuessMinIntervalSec = &v
@@ -1074,6 +1100,158 @@ func (s *Store) UpdateTaskConfig(ctx context.Context, id, displayName, descripti
return nil
}
func (s *Store) SetTaskRetireAfterCompletion(ctx context.Context, id string, retire bool) error {
v := 0
if retire {
v = 1
}
res, err := s.DB.ExecContext(ctx, `UPDATE tasks SET retire_after_completion=?,revision=revision+1 WHERE id=?`, v, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("task not found")
}
return nil
}
// TransferArtifact changes only the current collectible owner. Historical
// winner/worker provenance remains untouched and the transfer is audit logged.
func (s *Store) TransferArtifact(ctx context.Context, taskID, targetClientID, reason string) (string, error) {
taskID = strings.TrimSpace(taskID)
targetClientID = strings.TrimSpace(targetClientID)
reason = strings.TrimSpace(reason)
if taskID == "" || targetClientID == "" {
return "", fmt.Errorf("task and target client are required")
}
if len(reason) > 500 {
return "", fmt.Errorf("transfer reason too long (max 500)")
}
tx, err := s.DB.BeginTx(ctx, nil)
if err != nil {
return "", err
}
defer tx.Rollback()
var targetExists int
if err := tx.QueryRowContext(ctx, `SELECT count(*) FROM clients WHERE id=?`, targetClientID).Scan(&targetExists); err != nil {
return "", err
}
if targetExists != 1 {
return "", fmt.Errorf("target client not found")
}
var status, artifactStatus, from string
if err := tx.QueryRowContext(ctx, `SELECT status,artifact_status,COALESCE(artifact_owner_client_id,winner_client_id,'') FROM tasks WHERE id=?`, taskID).Scan(&status, &artifactStatus, &from); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", fmt.Errorf("task not found")
}
return "", err
}
if status != "completed" || from == "" || artifactStatus == "none" {
return "", fmt.Errorf("task has no transferable collectible")
}
if artifactStatus == "generating" {
return "", fmt.Errorf("collectible is currently generating; retry after generation finishes")
}
if from == targetClientID {
return from, tx.Commit()
}
if _, err := tx.ExecContext(ctx, `UPDATE tasks SET artifact_owner_client_id=? WHERE id=?`, targetClientID, taskID); err != nil {
return "", err
}
if _, err := tx.ExecContext(ctx, `INSERT INTO artifact_transfers(id,task_id,from_client_id,to_client_id,reason,created_at) VALUES(?,?,?,?,?,?)`, NewID("xfer_"), taskID, from, targetClientID, reason, time.Now().UTC().UnixMilli()); err != nil {
return "", err
}
if err := tx.Commit(); err != nil {
return "", err
}
return from, nil
}
// CreateAdminArtifactDrop queues a collectible for an existing client without
// recording a game win. A random task is used as the art-direction template
// when templateTaskID is empty; the new seed/id still randomize collection traits.
func (s *Store) CreateAdminArtifactDrop(ctx context.Context, targetClientID, templateTaskID string, fallbackBits int) (Task, error) {
targetClientID = strings.TrimSpace(targetClientID)
templateTaskID = strings.TrimSpace(templateTaskID)
if targetClientID == "" {
return Task{}, fmt.Errorf("target client is required")
}
if fallbackBits < 8 || fallbackBits > 128 {
fallbackBits = 28
}
tx, err := s.DB.BeginTx(ctx, nil)
if err != nil {
return Task{}, err
}
defer tx.Rollback()
var exists int
if err := tx.QueryRowContext(ctx, `SELECT count(*) FROM clients WHERE id=?`, targetClientID).Scan(&exists); err != nil {
return Task{}, err
}
if exists != 1 {
return Task{}, fmt.Errorf("target client not found")
}
var template Task
var templateErr error
if templateTaskID != "" {
template, _, templateErr = scanTask(tx.QueryRowContext(ctx, `SELECT `+taskColumns+` FROM tasks WHERE id=?`, templateTaskID), false)
} else {
template, _, templateErr = scanTask(tx.QueryRowContext(ctx, `SELECT `+taskColumns+` FROM tasks WHERE artifact_origin='win' ORDER BY RANDOM() LIMIT 1`), false)
}
if templateErr != nil && !errors.Is(templateErr, sql.ErrNoRows) {
return Task{}, templateErr
}
if templateTaskID != "" && errors.Is(templateErr, sql.ErrNoRows) {
return Task{}, fmt.Errorf("template task not found")
}
bits := template.RangeBits
if bits < 8 || bits > 128 {
bits = fallbackBits
}
secret, err := core.RandomDecimal(bits)
if err != nil {
return Task{}, err
}
seed, err := core.RandomSeed()
if err != nil {
return Task{}, err
}
id := NewID("drop_")
now := time.Now().UTC().UnixMilli()
name := template.DisplayName
if strings.TrimSpace(name) == "" {
name = "RIFT RANDOM DROP"
}
var guessMin, clientSubmit any
if template.GuessMinIntervalSec != nil {
guessMin = *template.GuessMinIntervalSec
}
if template.ClientSubmitIntervalSec != nil {
clientSubmit = *template.ClientSubmitIntervalSec
}
_, err = tx.ExecContext(ctx, `INSERT INTO tasks(
id,secret,public_seed,range_bits,status,paused,guess_min_interval_sec,client_submit_interval_sec,revision,parent_task_id,
display_name,description,nft_prompt_instructions,nft_negative_prompt,nft_style_reference,retire_after_completion,
artifact_owner_client_id,artifact_origin,created_at,completed_at,winner_signature,winning_guess,artifact_status)
VALUES(?,?,?,?,'completed',0,?,?,0,NULL,?,?,?,?,?,1,?,'admin_drop',?,?,?,?, 'pending')`,
id, secret, seed, bits, guessMin, clientSubmit, name, template.Description, template.NFTPromptInstructions, template.NFTNegativePrompt, template.NFTStyleReference,
targetClientID, now, now, "admin-drop:"+NewID("sig_"), "")
if err != nil {
return Task{}, err
}
out, _, err := scanTask(tx.QueryRowContext(ctx, `SELECT `+taskColumns+` FROM tasks WHERE id=?`, id), false)
if err != nil {
return Task{}, err
}
if err := tx.Commit(); err != nil {
return Task{}, err
}
return out, nil
}
func (s *Store) SetTaskStyleReference(ctx context.Context, id, styleReference string) error {
styleReference = strings.TrimSpace(styleReference)
if len(styleReference) > 180 {
@@ -1416,7 +1594,7 @@ func (s *Store) InactiveNonWinnerClients(ctx context.Context, cutoffMS int64) ([
rows, err := s.DB.QueryContext(ctx, `SELECT c.id,c.last_seen
FROM clients c
WHERE c.last_seen < ?
AND NOT EXISTS (SELECT 1 FROM tasks t WHERE t.winner_client_id=c.id OR t.winner_worker_client_id=c.id)
AND NOT EXISTS (SELECT 1 FROM tasks t WHERE t.winner_client_id=c.id OR t.winner_worker_client_id=c.id OR t.artifact_owner_client_id=c.id)
AND NOT EXISTS (SELECT 1 FROM identity_delegations d WHERE d.worker_client_id=c.id OR d.owner_client_id=c.id)
ORDER BY c.last_seen ASC`, cutoffMS)
if err != nil {
@@ -1440,7 +1618,7 @@ func (s *Store) OldWinnerCount(ctx context.Context, cutoffMS int64) (int64, erro
var n int64
err := s.DB.QueryRowContext(ctx, `SELECT count(*) FROM clients c
WHERE c.last_seen < ?
AND EXISTS (SELECT 1 FROM tasks t WHERE t.winner_client_id=c.id OR t.winner_worker_client_id=c.id)`, cutoffMS).Scan(&n)
AND EXISTS (SELECT 1 FROM tasks t WHERE t.winner_client_id=c.id OR t.winner_worker_client_id=c.id OR t.artifact_owner_client_id=c.id)`, cutoffMS).Scan(&n)
return n, err
}
@@ -1467,7 +1645,7 @@ func (s *Store) DeleteInactiveNonWinnerClients(ctx context.Context, cutoffMS int
_, _ = tx.ExecContext(ctx, `DELETE FROM presence_leases WHERE client_id=? AND expires_at<=?`, id, time.Now().UTC().UnixMilli())
res, err := tx.ExecContext(ctx, `DELETE FROM clients
WHERE id=? AND last_seen < ?
AND NOT EXISTS (SELECT 1 FROM tasks t WHERE t.winner_client_id=clients.id OR t.winner_worker_client_id=clients.id)
AND NOT EXISTS (SELECT 1 FROM tasks t WHERE t.winner_client_id=clients.id OR t.winner_worker_client_id=clients.id OR t.artifact_owner_client_id=clients.id)
AND NOT EXISTS (SELECT 1 FROM identity_delegations d WHERE d.worker_client_id=clients.id OR d.owner_client_id=clients.id)`, id, cutoffMS)
if err != nil {
return nil, err
@@ -1613,7 +1791,7 @@ func (s *Store) PersistImprovement(ctx context.Context, t SecretTask, cid, rewar
if rewardOwner == "" {
rewardOwner = cid
}
res, err := tx.ExecContext(ctx, `UPDATE tasks SET status='completed',completed_at=?,winner_client_id=?,winner_worker_client_id=?,winner_beacon_path=?,winner_beacon_boosted_path=?,winner_beacon_round=?,winner_signature=?,winning_guess=?,artifact_status='pending',revision=revision+1 WHERE id=? AND status='active'`, lastMS, rewardOwner, cid, beaconPath, beaconBoostedPath, int64(beaconRound), sig, guess, t.ID)
res, err := tx.ExecContext(ctx, `UPDATE tasks SET status='completed',completed_at=?,winner_client_id=?,winner_worker_client_id=?,artifact_owner_client_id=?,artifact_origin='win',winner_beacon_path=?,winner_beacon_boosted_path=?,winner_beacon_round=?,winner_signature=?,winning_guess=?,artifact_status='pending',revision=revision+1 WHERE id=? AND status='active'`, lastMS, rewardOwner, cid, rewardOwner, beaconPath, beaconBoostedPath, int64(beaconRound), sig, guess, t.ID)
if err != nil {
return Point{}, err
}

View File

@@ -339,9 +339,12 @@ func (s *Server) Routes() http.Handler {
r.Get("/api/admin/settings", s.adminSettingsGet)
r.Put("/api/admin/settings", s.adminSettingsPut)
r.Get("/api/admin/tasks", s.adminTasks)
r.Get("/api/admin/clients", s.adminClients)
r.Get("/api/admin/tasks/{id}/points", s.adminPoints)
r.Get("/api/admin/tasks/{id}/actions", s.adminTaskActions)
r.Put("/api/admin/tasks/{id}/config", s.adminTaskConfigPut)
r.Post("/api/admin/tasks/{id}/artifact/transfer", s.adminArtifactTransfer)
r.Post("/api/admin/artifacts/drop", s.adminArtifactDrop)
r.Post("/api/admin/tasks/{id}/actions", s.adminScheduleAction)
r.Post("/api/admin/actions/{id}/cancel", s.adminCancelAction)
r.Get("/api/admin/artifact/providers", s.adminArtifactProviders)
@@ -563,6 +566,7 @@ func taskDTO(t data.Task, next int64, sm settings.Runtime) map[string]any {
"display_name": t.DisplayName,
"description": t.Description,
"parent_task_id": t.ParentTaskID,
"retire_after_completion": t.RetireAfterCompletion,
"created_at": t.CreatedAt,
}
}
@@ -888,8 +892,10 @@ func (s *Server) guess(w http.ResponseWriter, r *http.Request) {
if successor, succErr := s.store.EnsureSuccessorTask(r.Context(), id, s.settings.Get().TaskRangeBits); succErr == nil {
dataOut["successor_task_id"] = successor.ID
s.runtime.ReplaceTaskSelection(id, successor.ID)
} else {
} else if !errors.Is(succErr, data.ErrTaskRetiring) {
log.Printf("successor for %s: %v", id, succErr)
} else {
dataOut["retired"] = "true"
}
_ = s.hub.Publish(r.Context(), wsx.Event{Type: "task_completed", TaskID: id, Data: dataOut})
_ = s.store.EnsureActiveTasks(r.Context(), s.settings.Get().ActiveTaskCount, s.settings.Get().TaskRangeBits)
@@ -1490,6 +1496,83 @@ func (s *Server) adminTasks(w http.ResponseWriter, r *http.Request) {
jsonOut(w, 200, ts)
}
func (s *Server) adminClients(w http.ResponseWriter, r *http.Request) {
q := strings.TrimSpace(r.URL.Query().Get("q"))
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit < 1 || limit > 500 {
limit = 200
}
rows, err := s.store.DB.QueryContext(r.Context(), `SELECT c.id,c.last_seen,
(SELECT count(*) FROM tasks t WHERE t.winner_client_id=c.id AND t.status='completed') AS wins,
(SELECT count(*) FROM tasks t WHERE COALESCE(t.artifact_owner_client_id,t.winner_client_id)=c.id AND t.status='completed' AND t.artifact_status='ready') AS nft_count
FROM clients c WHERE (?='' OR lower(c.id) LIKE '%'||lower(?)||'%') ORDER BY c.last_seen DESC LIMIT ?`, q, q, limit)
if err != nil {
jsonOut(w, 500, map[string]string{"error": err.Error()})
return
}
defer rows.Close()
out := make([]map[string]any, 0)
for rows.Next() {
var id string
var lastSeen int64
var wins, nfts int
if err := rows.Scan(&id, &lastSeen, &wins, &nfts); err != nil {
jsonOut(w, 500, map[string]string{"error": err.Error()})
return
}
out = append(out, map[string]any{"client_id": id, "last_seen": lastSeen, "wins": wins, "nft_count": nfts})
}
jsonOut(w, 200, out)
}
func (s *Server) adminArtifactTransfer(w http.ResponseWriter, r *http.Request) {
var in struct {
TargetClientID string `json:"target_client_id"`
Reason string `json:"reason"`
}
if err := decode(r, &in); err != nil || strings.TrimSpace(in.TargetClientID) == "" {
jsonOut(w, 400, map[string]string{"error": "target_client_id required"})
return
}
taskID := chi.URLParam(r, "id")
from, err := s.store.TransferArtifact(r.Context(), taskID, in.TargetClientID, in.Reason)
if err != nil {
jsonOut(w, 400, map[string]string{"error": err.Error()})
return
}
_ = s.hub.Publish(r.Context(), wsx.Event{Type: "task_changed", TaskID: taskID, Data: map[string]string{"action": "artifact_transfer"}})
jsonOut(w, 200, map[string]any{"ok": true, "from_client_id": from, "to_client_id": strings.TrimSpace(in.TargetClientID)})
}
func (s *Server) adminArtifactDrop(w http.ResponseWriter, r *http.Request) {
var in struct {
TargetClientID string `json:"target_client_id"`
TemplateTaskID string `json:"template_task_id"`
Count int `json:"count"`
}
if err := decode(r, &in); err != nil || strings.TrimSpace(in.TargetClientID) == "" {
jsonOut(w, 400, map[string]string{"error": "target_client_id required"})
return
}
if in.Count == 0 {
in.Count = 1
}
if in.Count < 1 || in.Count > 20 {
jsonOut(w, 400, map[string]string{"error": "count must be 1..20"})
return
}
created := make([]string, 0, in.Count)
for i := 0; i < in.Count; i++ {
t, err := s.store.CreateAdminArtifactDrop(r.Context(), in.TargetClientID, in.TemplateTaskID, s.settings.Get().TaskRangeBits)
if err != nil {
jsonOut(w, 400, map[string]any{"error": err.Error(), "created_task_ids": created})
return
}
created = append(created, t.ID)
}
jsonOut(w, 201, map[string]any{"ok": true, "created_task_ids": created, "artifact_status": "pending"})
}
func (s *Server) adminTaskConfigPut(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var in struct {
@@ -1497,6 +1580,7 @@ func (s *Server) adminTaskConfigPut(w http.ResponseWriter, r *http.Request) {
Description string `json:"description"`
NFTPromptInstructions string `json:"nft_prompt_instructions"`
NFTNegativePrompt string `json:"nft_negative_prompt"`
RetireAfterCompletion bool `json:"retire_after_completion"`
}
if err := decode(r, &in); err != nil {
jsonOut(w, 400, map[string]string{"error": "bad json: " + err.Error()})
@@ -1506,6 +1590,10 @@ func (s *Server) adminTaskConfigPut(w http.ResponseWriter, r *http.Request) {
jsonOut(w, 400, map[string]string{"error": err.Error()})
return
}
if err := s.store.SetTaskRetireAfterCompletion(r.Context(), id, in.RetireAfterCompletion); err != nil {
jsonOut(w, 400, map[string]string{"error": err.Error()})
return
}
_ = s.hub.Publish(r.Context(), wsx.Event{Type: "task_changed", TaskID: id, Data: map[string]string{"action": "config"}})
jsonOut(w, 200, map[string]bool{"ok": true})
}
@@ -1703,8 +1791,10 @@ func (s *Server) runTaskAction(ctx context.Context, a data.TaskAction) {
if successor, succErr := s.store.EnsureSuccessorTask(ctx, a.TaskID, s.settings.Get().TaskRangeBits); succErr == nil {
dataOut["successor_task_id"] = successor.ID
s.runtime.ReplaceTaskSelection(a.TaskID, successor.ID)
} else {
} else if !errors.Is(succErr, data.ErrTaskRetiring) {
log.Printf("successor for %s: %v", a.TaskID, succErr)
} else {
dataOut["retired"] = "true"
}
_ = s.hub.Publish(ctx, wsx.Event{Type: "task_completed", TaskID: a.TaskID, Data: dataOut})
_ = s.store.EnsureActiveTasks(ctx, s.settings.Get().ActiveTaskCount, s.settings.Get().TaskRangeBits)
@@ -1734,8 +1824,10 @@ func (s *Server) adminCloseTask(w http.ResponseWriter, r *http.Request) {
if successor, succErr := s.store.EnsureSuccessorTask(r.Context(), id, s.settings.Get().TaskRangeBits); succErr == nil {
dataOut["successor_task_id"] = successor.ID
s.runtime.ReplaceTaskSelection(id, successor.ID)
} else {
} else if !errors.Is(succErr, data.ErrTaskRetiring) {
log.Printf("successor for %s: %v", id, succErr)
} else {
dataOut["retired"] = "true"
}
_ = s.hub.Publish(r.Context(), wsx.Event{Type: "task_completed", TaskID: id, Data: dataOut})
_ = s.store.EnsureActiveTasks(r.Context(), s.settings.Get().ActiveTaskCount, s.settings.Get().TaskRangeBits)

View File

@@ -397,7 +397,7 @@ async function runUser(){
$('toggleMobile').onclick=()=>toggleMobileMode();$('toggleDetails').onclick=()=>{detailsOpen=!detailsOpen;$('signalPanel').classList.toggle('expanded',detailsOpen);setActive('toggleDetails',detailsOpen)};window.addEventListener('neuralhunt-mobile-mode',syncMobile);
$('toggleProximity').onclick=()=>{map.proximityFocus=!map.proximityFocus;$('toggleProximity').textContent=map.proximityFocus?'TARGET FIELD':'RAW 3D';setActive('toggleProximity',map.proximityFocus)};$('toggleRotate').onclick=()=>{map.autoRotate=!map.autoRotate;setActive('toggleRotate',map.autoRotate)};$('toggleLabels').onclick=()=>{map.labels=!map.labels;setActive('toggleLabels',map.labels)};$('toggleEdges').onclick=()=>{map.edges=!map.edges;setActive('toggleEdges',map.edges)};$('toggleShells').onclick=()=>{map.shells=!map.shells;setActive('toggleShells',map.shells)};$('toggleLOD').onclick=()=>{map.lodEnabled=!map.lodEnabled;map.rebuild();setActive('toggleLOD',map.lodEnabled)};$('toggleEco').onclick=()=>{map.eco=!map.eco;map.resize();setActive('toggleEco',map.eco)};$('resetView').onclick=()=>map.resetView();
async function downloadMyNFT(n){const r=await fetch(n.download_uri,{headers:{Authorization:'Bearer '+getToken()},credentials:'same-origin'});if(!r.ok)throw new Error(await responseError(r,'Original konnte nicht geladen werden'));const blob=await r.blob(),a=document.createElement('a'),ext=(blob.type==='image/svg+xml'?'.svg':blob.type==='image/png'?'.png':blob.type==='image/webp'?'.webp':blob.type==='image/jpeg'?'.jpg':'');a.href=URL.createObjectURL(blob);a.download=`neuralhunt-${n.task_id}${ext}`;a.click();setTimeout(()=>URL.revokeObjectURL(a.href),5000)}
async function renderOwnedNFTs(host){host.innerHTML='<span class="identity-nft-empty">lade …</span>';try{const items=await api('/api/me/artifacts?limit=24');host.innerHTML=items?.length?items.map(n=>`<div class="identity-nft-row"><div><b>${esc(n.display_name||'WINNER NFT')}</b><small>${esc(shortID(n.task_id,12))} · ${Number(n.range_bits||0)} Bit · ${esc(fmtDate(n.completed_at))}</small></div><button data-my-nft="${esc(n.task_id)}">ORIGINAL</button></div>`).join(''):'<span class="identity-nft-empty">Noch keine fertigen Gewinner-Artefakte für diese Identität.</span>';host.querySelectorAll('[data-my-nft]').forEach(b=>b.onclick=async()=>{const n=items.find(x=>x.task_id===b.dataset.myNft);if(!n)return;b.disabled=true;try{await downloadMyNFT(n)}catch(e){status(e.message||'Download fehlgeschlagen')}finally{b.disabled=false}})}catch(e){host.innerHTML=`<span class="identity-nft-empty">${esc(e.message||'NFTs konnten nicht geladen werden')}</span>`}}
async function renderOwnedNFTs(host){host.innerHTML='<span class="identity-nft-empty">lade …</span>';try{const items=await api('/api/me/artifacts?limit=24');host.innerHTML=items?.length?items.map(n=>`<div class="identity-nft-row"><div><b>${esc(n.display_name||(n.artifact_origin==='admin_drop'?'ADMIN DROP':'WINNER NFT'))}</b><small>${n.artifact_origin==='admin_drop'?'ADMIN DROP · ':''}${esc(shortID(n.task_id,12))} · ${Number(n.range_bits||0)} Bit · ${esc(fmtDate(n.completed_at))}</small></div><button data-my-nft="${esc(n.task_id)}">ORIGINAL</button></div>`).join(''):'<span class="identity-nft-empty">Noch keine fertigen Gewinner-Artefakte für diese Identität.</span>';host.querySelectorAll('[data-my-nft]').forEach(b=>b.onclick=async()=>{const n=items.find(x=>x.task_id===b.dataset.myNft);if(!n)return;b.disabled=true;try{await downloadMyNFT(n)}catch(e){status(e.message||'Download fehlgeschlagen')}finally{b.disabled=false}})}catch(e){host.innerHTML=`<span class="identity-nft-empty">${esc(e.message||'NFTs konnten nicht geladen werden')}</span>`}}
async function toggleOwnedNFTs(host){if(!host)return;if(!host.classList.contains('hidden')){host.classList.add('hidden');return}host.classList.remove('hidden');await renderOwnedNFTs(host)}
async function performIdentityExport(){const p=prompt('Passphrase für den verschlüsselten Identitäts-Export (mindestens 12 Zeichen). Bewahre Export und Passphrase getrennt auf.');if(!p)return;try{const text=await exportIdentity(p),a=document.createElement('a');a.href=URL.createObjectURL(new Blob([text],{type:'application/json'}));a.download=`neuralhunt-identity-${cid.slice(0,10)}.json`;a.click();setTimeout(()=>URL.revokeObjectURL(a.href),5000);status('Identität verschlüsselt gesichert')}catch(e){status(e.message||'Export fehlgeschlagen')}}
async function performHostedLinkCode(){try{const x=await api('/api/me/customer-link',{method:'POST',body:JSON.stringify({})});const code=x.code||'';if(!code)throw new Error('Kein Hosted-Code erhalten');try{await navigator.clipboard?.writeText(code)}catch{}prompt('Einmaliger Hosted-Code (10 Minuten gültig). Im Customer-Service-Portal unter Haupt-Identität einfügen:',code);status('Hosted-Code erzeugt · nur einmal verwendbar')}catch(e){status(e.message||'Hosted-Code konnte nicht erzeugt werden')}}
@@ -461,7 +461,7 @@ function adminShell(){
async function runAdmin(){
let adminOK=false;try{await api('/api/admin/session',{},true);adminOK=true}catch{}if(!adminOK){adminLoginShell();$('adminlogin').onclick=async()=>{try{const r=await fetch('/api/admin/login',{method:'POST',headers:{'Content-Type':'application/json'},credentials:'same-origin',body:JSON.stringify({user:$('adminuser').value,password:$('adminpass').value})});if(!r.ok)throw new Error('Login fehlgeschlagen');location.reload()}catch(e){$('adminerr').textContent=e.message}};return}
adminShell();const map=new NeuralMap($('adminmap'),{admin:true,onStats:s=>{if($('adminrender'))$('adminrender').textContent=s.render.toLocaleString('de-DE');if($('adminfps'))$('adminfps').textContent=s.fps}});const adminGuessFlashKey='neuralhunt.adminGuessFlash.v1';let adminGuessFlash=false;try{adminGuessFlash=localStorage.getItem(adminGuessFlashKey)==='1'}catch{}map.guessFlashEnabled=adminGuessFlash;setActive('adminGuessFlash',adminGuessFlash);const adminDraftKey='neuralhunt.adminDraft.v2';let draft={};try{draft=JSON.parse(localStorage.getItem(adminDraftKey)||'{}')||{}}catch{draft={}};let tasks=[],selected=null,points=[],settings=null,actions=[],providers=null,artifactUsage=null,poll=null,tab=['runtime','task','artifact'].includes(draft.tab)?draft.tab:'runtime',loading=false;
adminShell();const map=new NeuralMap($('adminmap'),{admin:true,onStats:s=>{if($('adminrender'))$('adminrender').textContent=s.render.toLocaleString('de-DE');if($('adminfps'))$('adminfps').textContent=s.fps}});const adminGuessFlashKey='neuralhunt.adminGuessFlash.v1';let adminGuessFlash=false;try{adminGuessFlash=localStorage.getItem(adminGuessFlashKey)==='1'}catch{}map.guessFlashEnabled=adminGuessFlash;setActive('adminGuessFlash',adminGuessFlash);const adminDraftKey='neuralhunt.adminDraft.v2';let draft={};try{draft=JSON.parse(localStorage.getItem(adminDraftKey)||'{}')||{}}catch{draft={}};let tasks=[],selected=null,points=[],settings=null,actions=[],providers=null,artifactUsage=null,clients=[],poll=null,tab=['runtime','task','artifact'].includes(draft.tab)?draft.tab:'runtime',loading=false;
const setAdminPanel=name=>{const grid=$('adminGrid');grid.classList.remove('mobile-show-map','mobile-show-tasks','mobile-show-control');grid.classList.add(`mobile-show-${name}`);document.querySelectorAll('[data-admin-panel]').forEach(b=>b.classList.toggle('active',b.dataset.adminPanel===name));if(name==='map')setTimeout(()=>map.resize(),30)};
const syncAdminMobile=()=>{const on=document.documentElement.classList.contains('mobile-mode');$('adminMobileToggle').textContent=mobileButtonLabel();setActive('adminMobileToggle',on);if(on){map.eco=true;map.labels=false;map.edges=false;map.zoom=Math.min(map.zoom,.94);setActive('adminEco',true);setActive('adminEdges',false);if(+$('adminmaxnodes').value>1500){$('adminmaxnodes').value=1500;$('adminmaxvalue').textContent='1.500'}}else{map.eco=false;setActive('adminEco',false)}map.resize()};
$('adminMobileToggle').onclick=()=>toggleMobileMode();document.querySelectorAll('[data-admin-panel]').forEach(b=>b.onclick=()=>setAdminPanel(b.dataset.adminPanel));window.addEventListener('neuralhunt-mobile-mode',syncAdminMobile);syncAdminMobile();
@@ -481,7 +481,7 @@ async function runAdmin(){
function renderOverview(o){$('overview').innerHTML=`<span><b>${o.connected}</b> verbunden</span><span><b>${o.clients}</b> Identitäten</span><span><b>${o.active_tasks}</b> aktive Tasks</span><span><b>${o.completed_tasks}</b> abgeschlossen</span><span><b>${Number(o.guesses||0).toLocaleString('de-DE')}</b> Tipps</span><span><b>${o.artifacts_ready}</b> Artefakte</span>`}
function renderPerformance(p){const r=p?.runtime||{},w=p?.websocket||{},x=p?.process||{};$('performance').innerHTML=`<span><b>${Number(r.guesses_per_sec||0).toFixed(1)}</b> Guess/s</span><span><b>${Number(r.rejected_per_sec||0).toFixed(1)}</b> Reject/s</span><span><b>${Number(r.improvements_per_sec||0).toFixed(1)}</b> Improve/s</span><span><b>${Number(r.sqlite_writes_per_sec||0).toFixed(1)}</b> SQLite W/s</span><span><b>${Number(w.frames_per_sec||0).toFixed(0)}</b> WS Frames/s</span><span><b>${(Number(w.bytes_per_sec||0)/1048576).toFixed(2)}</b> WS MB/s</span><span><b>${Number(w.dropped_per_sec||0).toFixed(1)}</b> Drops/s</span><span><b>${Number(x.goroutines||0).toLocaleString('de-DE')}</b> Goroutines</span><span><b>${(Number(x.heap_bytes||0)/1048576).toFixed(1)}</b> Heap MB</span>`}
async function openAdminFile(taskID,kind){const popup=window.open('','_blank');try{const r=await fetch(`/api/admin/tasks/${encodeURIComponent(taskID)}/${kind}`,{credentials:'same-origin'});if(!r.ok)throw new Error(await responseError(r,'Datei konnte nicht geöffnet werden'));const blob=await r.blob(),u=URL.createObjectURL(blob);if(popup)popup.location=u;else{const a=document.createElement('a');a.href=u;a.target='_blank';a.click()}setTimeout(()=>URL.revokeObjectURL(u),60000)}catch(e){if(popup)popup.close();msg(e.message)}}
function renderTasks(){tasks=Array.isArray(tasks)?tasks:[];$('taskCount').textContent=tasks.length;$('tasks').innerHTML=tasks.length?tasks.map(t=>{const aerr=String(t.artifact_error||'').trim();return `<button data-id="${esc(t.id)}" class="${selected?.id===t.id?'selected':''}"><span><b>${esc(t.display_name||t.id.slice(-12))}</b><small>${esc(t.id.slice(-12))} · ${fmtDate(t.created_at)}</small></span><span><em class="task-state ${esc(t.status)}">${t.paused?'PAUSED':esc(t.status)}</em><small>${t.range_bits} Bit · rev ${t.revision} · ${Number(t.point_count||0).toLocaleString('de-DE')} Clients · ${Number(t.guess_count||0).toLocaleString('de-DE')} Tipps</small></span><span class="artifact-state-wrap"><b class="artifact-state ${esc(t.artifact_status||'')}">${esc(t.artifact_status||'—')}</b>${aerr?`<small class="danger artifact-error-summary" title="${esc(aerr)}">${esc(aerr.length>76?aerr.slice(0,75)+'…':aerr)}</small>`:`<small>${t.parent_task_id?`${esc(String(t.parent_task_id).slice(-7))}`:'ROOT'}</small>`}<small class="artifactLinks">${t.artifact_uri?`<span data-artifact-task="${esc(t.id)}">Bild</span> · <span data-manifest-task="${esc(t.id)}">Manifest</span>`:''}</small></span></button>`}).join(''):'<div class="empty">Keine Tasks</div>';document.querySelectorAll('#tasks button[data-id]').forEach(b=>b.onclick=e=>{const art=e.target.closest('[data-artifact-task]'),man=e.target.closest('[data-manifest-task]');if(art){e.preventDefault();e.stopPropagation();openAdminFile(art.dataset.artifactTask,'artifact');return}if(man){e.preventDefault();e.stopPropagation();openAdminFile(man.dataset.manifestTask,'manifest');return}openTask(tasks.find(t=>t.id===b.dataset.id))})}
function renderTasks(){tasks=Array.isArray(tasks)?tasks:[];$('taskCount').textContent=tasks.length;$('tasks').innerHTML=tasks.length?tasks.map(t=>{const aerr=String(t.artifact_error||'').trim();return `<button data-id="${esc(t.id)}" class="${selected?.id===t.id?'selected':''}"><span><b>${esc(t.display_name||t.id.slice(-12))}</b><small>${esc(t.id.slice(-12))} · ${fmtDate(t.created_at)}</small></span><span><em class="task-state ${esc(t.status)}">${t.paused?'PAUSED':esc(t.status)}</em><small>${t.range_bits} Bit · rev ${t.revision} · ${Number(t.point_count||0).toLocaleString('de-DE')} Clients · ${Number(t.guess_count||0).toLocaleString('de-DE')} Tipps</small></span><span class="artifact-state-wrap"><b class="artifact-state ${esc(t.artifact_status||'')}">${esc(t.artifact_status||'—')}</b>${aerr?`<small class="danger artifact-error-summary" title="${esc(aerr)}">${esc(aerr.length>76?aerr.slice(0,75)+'…':aerr)}</small>`:`<small>${t.artifact_origin==='admin_drop'?'ADMIN DROP':t.retire_after_completion?'AUSLAUFEND':t.parent_task_id?`${esc(String(t.parent_task_id).slice(-7))}`:'ROOT'}</small>`}<small class="artifactLinks">${t.artifact_uri?`<span data-artifact-task="${esc(t.id)}">Bild</span> · <span data-manifest-task="${esc(t.id)}">Manifest</span>`:''}</small></span></button>`}).join(''):'<div class="empty">Keine Tasks</div>';document.querySelectorAll('#tasks button[data-id]').forEach(b=>b.onclick=e=>{const art=e.target.closest('[data-artifact-task]'),man=e.target.closest('[data-manifest-task]');if(art){e.preventDefault();e.stopPropagation();openAdminFile(art.dataset.artifactTask,'artifact');return}if(man){e.preventDefault();e.stopPropagation();openAdminFile(man.dataset.manifestTask,'manifest');return}openTask(tasks.find(t=>t.id===b.dataset.id))})}
function renderRuntime(){
$('settingfields').innerHTML=`<div class="control-section"><div class="section-title">GLOBAL RUNTIME</div>${runtimeKeys.map(k=>`<label><span>${esc(labels[k])}</span><input type="number" step="any" data-setting="${k}" value="${settings?.[k]??''}"></label>`).join('')}<p class="small">Die Tipp-Lotterie gilt <b>getrennt pro aktivem Task</b>. Bei einem Wert &gt; 0 werden alle gültigen Tipps eines Zeitfensters gesammelt und am Fensterende exakt bis zur eingestellten Menge zufällig gezogen. Nicht gezogene Tipps werden nicht gegen das Ziel geprüft und verändern den Score nicht; ihre Sequenz wird trotzdem verbraucht. <b>0 = Lotterie aus</b>. Änderungen greifen für neu beginnende Fenster.</p><p class="small"><b>Beacon Hunt:</b> Optional wählen Spieler PULSE, FLUX oder ORBIT. Der erste öffentliche drand-Round nach Fensterschluss bestimmt reproduzierbar den Boost-Pfad und die gewichtete Ziehung. Alle Reveal-Daten werden gespeichert und über die Public API nachvollziehbar gemacht.</p><p class="small"><b>Anti-Sybil:</b> Neue Browser-Identitäten lösen einmalig einen Proof-of-Work und warten anschließend die konfigurierte Warmup-Zeit, bevor Tipps gewertet werden. Das erhöht die Kosten massenhafter Identitätserstellung, ersetzt aber keine externe echte Identitätsprüfung.</p><p class="small"><b>OpenAI Circuit Breaker:</b> Vor jedem Bild-Call werden rollierende 1h-/24h-Call-Limits und das geschätzte 24h-Kostenbudget geprüft. Bei Überschreitung bleibt die Gewinnerkarte in der Queue und wird später erneut versucht.</p><p class="small">Die übrigen Defaults gelten global. Task-spezifische Intervalle und Zahlenräume steuerst du im Tab „Task Actions“.</p></div>
<div class="control-section profile-cleanup"><div class="section-title">ALTE PROFILE BEREINIGEN</div><p class="small">Löscht ausschließlich Accounts, die <b>seit mindestens X Zeit inaktiv</b>, aktuell <b>nicht verbunden</b> und <b>niemals Gewinner</b> eines Tasks waren. Gewinner werden unabhängig vom Alter immer geschützt. Zugehörige Punkte, Unlocks und Task-Auswahl werden mit dem Profil entfernt.</p>
@@ -505,12 +505,14 @@ async function runAdmin(){
<div class="reference-admin-box"><div><div class="section-title">RIFT CHARACTER ANCHOR</div><p class="small">Der Anchor definiert ausschließlich, <b>wer RIFT ist</b>. Er wird einmalig als neutrales Character-Referenzbild erzeugt und anschließend für alle Tasks verwendet. Die visuelle Stilrichtung kommt getrennt aus der Style-Referenz des jeweiligen Tasks.</p>${anchorReady?'<p class="small ready-copy">Anchor vorhanden · Identität ist gesperrt. Es gibt absichtlich keinen Überschreiben-Button.</p>':'<p class="small">Du kannst den Anchor jetzt kontrolliert erzeugen. Falls du das nicht tust, erzeugt der Worker ihn weiterhin automatisch beim ersten Gewinnerbild als Sicherheits-Fallback.</p>'}</div><div class="reference-preview ${anchorReady?'has-image':''}">${anchorReady?'<img id="anchorPreview" alt="RIFT Character Anchor">':'<span>NO ANCHOR</span>'}</div></div>
${anchorReady?'':`<div class="task-config-actions anchor-actions"><button id="createCharacterAnchor" ${ps.openai?'':'disabled'}>RIFT-ANCHOR JETZT ERZEUGEN</button></div>`}
<div class="task-config-box"><div class="section-title">PIPELINE</div><p class="small">Provider <b>OpenAI</b> · Ausgabe <b>1024 × 1536</b> · Quality <b>${esc(settings?.artifact_quality||'medium')}</b> · Preset <b>raccoon_full_art_v1</b>.</p><p class="small">Jede Karten-Generierung sendet zwei getrennte Referenzen: <b>Image 1 = globaler RIFT-Character-Anchor</b>, <b>Image 2 = Style-Referenz des gewählten Tasks</b>. Ohne eigenen Task-Style wird das eingebettete <code>internal/artifact/assets/style_reference.jpg</code> nur als Default-Style verwendet.</p><p class="small">Theme, Kleidung, Accessoires, Szene, Pose, Stimmung, Farb-Akzente und Rarity werden deterministisch aus Task/Winner/Seed gewählt. Das Modell erzeugt nur die Full-Art-Illustration; das finale Kartenlayout wird anschließend programmgesteuert aufgebaut.</p></div>
<div class="task-config-box admin-drop-box"><div class="section-title">ADMIN NFT DROP</div><p class="small">Erzeugt 120 zufällige RIFT-Collectibles für eine vorhandene Client-Identität, ohne einen Spielgewinn zu buchen. Die Karten laufen normal durch Artifact-Queue und OpenAI-Circuit-Breaker. Bei leerem Template wird pro Karte zufällig eine bestehende Task-Serie als Art-Direction verwendet.</p><label class="wide"><span>Ziel Client-ID</span><input id="dropTargetClient" data-draft="dropTargetClient" list="adminDropClientIDs" placeholder="Client-ID"></label><datalist id="adminDropClientIDs">${clients.map(c=>`<option value="${esc(c.client_id)}">${Number(c.wins||0)} Wins · ${Number(c.nft_count||0)} NFTs</option>`).join('')}</datalist><label><span>Anzahl</span><input id="dropCount" data-draft="dropCount" type="number" min="1" max="20" value="1"></label><label class="wide"><span>Art-Direction / Template</span><select id="dropTemplateTask" data-draft="dropTemplateTask"><option value="">ZUFÄLLIG AUS BESTEHENDEN TASKS</option>${tasks.filter(t=>t.artifact_origin!=='admin_drop').map(t=>`<option value="${esc(t.id)}">${esc(t.display_name||t.id.slice(-12))} · ${Number(t.range_bits||0)} Bit</option>`).join('')}</select></label><div class="task-config-actions"><button id="createAdminDrop">NFT-DROP IN QUEUE STELLEN</button></div><div id="adminDropResult" class="small"></div></div>
<div class="section-title">OPENAI NUTZUNG & KOSTEN</div><div class="cost-grid"><div class="cost-card"><small>KOSTEN HEUTE</small><b id="artifactCostToday">${usd(u.today_cost_usd,4)}</b><span id="artifactCostTodayMeta">${Number(u.today_calls||0).toLocaleString('de-DE')} API-Calls · ${Number(u.today_cards||0).toLocaleString('de-DE')} Karten${Number(u.today_calls||0)>Number(u.today_priced_calls||0)?` · ${(Number(u.today_calls||0)-Number(u.today_priced_calls||0)).toLocaleString('de-DE')} ohne Kostendaten`:''}</span></div><div class="cost-card"><small>Ø KOSTEN PRO KARTE</small><b id="artifactAvgCardCost">${usd(u.avg_card_cost_usd,5)}</b><span id="artifactAvgCardCostMeta">${Number(u.priced_card_generations||0).toLocaleString('de-DE')} bepreiste Generierungen</span></div><div class="cost-card"><small>KOSTEN PRO 1.000 KARTEN</small><b id="artifactCostPer1000">${usd(u.cost_per_1000_usd,2)}</b><span>hochgerechnet aus dem bisherigen Kartenmittel</span></div><div class="cost-card"><small>CIRCUIT BREAKER</small><b id="artifactCircuitState">${cb.blocked?'BLOCKED':'READY'}</b><span id="artifactCircuitMeta">${Number(cb.calls_1h||0).toLocaleString('de-DE')} / ${Number(cb.max_calls_1h||0).toLocaleString('de-DE')} Calls 1h · ${Number(cb.calls_24h||0).toLocaleString('de-DE')} / ${Number(cb.max_calls_24h||0).toLocaleString('de-DE')} Calls 24h · ${usd(cb.cost_24h_usd,3)} / ${usd(cb.max_cost_24h_usd,2)}</span></div></div>
<div class="usage-note">Die Token-Nutzung stammt direkt aus der OpenAI-Antwort. Die USD-Werte werden lokal daraus mit fest hinterlegten öffentlichen Standardpreisen berechnet; sie sind kein Rechnungsabgleich. Anchor-Kosten zählen in „heute“, aber nicht in den Karten-Durchschnitt.</div>
<div class="usage-table-wrap"><table class="usage-table"><thead><tr><th>Zeit</th><th>Typ</th><th>Modell</th><th>Tokens (Text + Bild → Output)</th><th>Kosten</th><th>Request</th></tr></thead><tbody id="artifactUsageRows">${usageRows}</tbody></table></div>
<p class="small">Task-spezifische Style-Bilder und optionale kreative Vorgaben pflegst du im Tab <b>TASK ACTIONS</b>.</p></div>`;
if(anchorReady){const img=$('anchorPreview');loadProtectedImage('/api/admin/artifact/character-anchor?ts='+Date.now(),img,true).catch(()=>{if(img)img.alt='Anchor konnte nicht geladen werden'})}
const create=$('createCharacterAnchor');if(create)create.onclick=async()=>{if(!confirm('RIFT Character Anchor jetzt einmalig erzeugen? Danach wird er absichtlich nicht automatisch überschrieben.'))return;create.disabled=true;create.textContent='ANCHOR WIRD ERZEUGT …';try{await api('/api/admin/artifact/character-anchor',{method:'POST'},true);msg('RIFT Character Anchor erzeugt und gesperrt');await load(true,true)}catch(e){msg(e.message);create.disabled=false;create.textContent='RIFT-ANCHOR JETZT ERZEUGEN'}};
const drop=$('createAdminDrop');if(drop)drop.onclick=async()=>{const target=String($('dropTargetClient')?.value||'').trim(),count=Number($('dropCount')?.value||1),template=String($('dropTemplateTask')?.value||'').trim();if(!target){msg('Ziel Client-ID fehlt');return}if(!Number.isInteger(count)||count<1||count>20){msg('Anzahl muss 120 sein');return}if(!confirm(`${count} Admin-NFT${count===1?'':'s'} für ${target.slice(0,18)}… erzeugen?\n\nDies kann API-Kosten auslösen; der Circuit-Breaker bleibt aktiv.`))return;drop.disabled=true;drop.textContent='WIRD EINGEREIHT …';try{const r=await api('/api/admin/artifacts/drop',{method:'POST',body:JSON.stringify({target_client_id:target,template_task_id:template,count})},true);$('adminDropResult').textContent=`${(r.created_task_ids||[]).length} Collectible(s) pending · ${(r.created_task_ids||[]).map(x=>x.slice(-10)).join(', ')}`;msg('Admin NFT-Drop eingereiht');await load(true,false)}catch(e){msg(e.message)}finally{drop.disabled=false;drop.textContent='NFT-DROP IN QUEUE STELLEN'}};
}else{
$('settingfields').innerHTML=`<div class="control-section"><div class="section-title">LEGACY ARTIFACT GENERATION</div><div class="provider-status">${['local','openai','comfyui','a1111'].map(k=>`<span class="${ps[k]?'ready':'off'}">${k.toUpperCase()} · ${ps[k]?'READY':'ENV FEHLT'}</span>`).join('')}</div>
<label><span>Provider</span><select data-setting-string="artifact_provider"><option value="local">local</option><option value="openai">openai</option><option value="comfyui">comfyui</option><option value="a1111">a1111</option><option value="auto">auto fallback</option></select></label>
@@ -527,15 +529,19 @@ async function runAdmin(){
$('savesettings').style.display='none';$('ensuretasks').style.display='none';if(!selected){$('settingfields').innerHTML='<div class="empty">Links einen Task auswählen.</div>';return}
const customStyle=!!String(selected.nft_style_reference||'').trim();
const artifactErr=String(selected.artifact_error||'').trim();
const artifactOwner=String(selected.artifact_owner_client_id||selected.winner_client_id||'').trim();
const artifactDiag=selected.artifact_status==='error'?`<div class="artifact-diagnostic"><div><b>NFT-GENERIERUNG FEHLGESCHLAGEN</b><span>${esc(artifactErr||'Unbekannter Artifact-Fehler')}</span></div><small>Die Meldung stammt direkt aus <code>tasks.artifact_error</code>. „NFT-Neugenerierung anstoßen · DONE“ bedeutet nur, dass die Admin-Aktion die Karte erneut in die Queue gestellt hat.</small></div>`:'';
$('settingfields').innerHTML=`<div class="control-section task-control"><div class="section-title">TASK ${esc(selected.display_name||selected.id.slice(-12))}</div><div class="task-facts"><span><b>${selected.range_bits}</b> Bit</span><span><b>${selected.paused?'PAUSED':selected.status}</b> Status</span><span><b>${selected.revision}</b> Revision</span></div>${artifactDiag}
<div class="task-config-box"><div class="section-title">TASK-DARSTELLUNG & RIFT ART-DIRECTION</div>${selected.parent_task_id?`<span class="inherit-badge">↳ geerbt von ${esc(String(selected.parent_task_id).slice(-12))}</span>`:'<span class="inherit-badge">ROOT TASK</span>'}<p class="draft-note">Der Folge-Task erbt Anzeigename, Beschreibung, kreative Vorgaben, Ausschlüsse und die Style-Referenz. Entwürfe in Textfeldern bleiben bei Auto-Refresh erhalten.</p>
$('settingfields').innerHTML=`<div class="control-section task-control"><div class="section-title">TASK ${esc(selected.display_name||selected.id.slice(-12))}</div><div class="task-facts"><span><b>${selected.range_bits}</b> Bit</span><span><b>${selected.paused?'PAUSED':selected.status}</b> Status</span><span><b>${selected.revision}</b> Revision</span><span><b>${selected.retire_after_completion?'AUSLAUFEND':'FORTLAUFEND'}</b> Serie</span></div>${artifactDiag}
<div class="task-config-box"><div class="section-title">TASK-DARSTELLUNG & RIFT ART-DIRECTION</div>${selected.parent_task_id?`<span class="inherit-badge">↳ geerbt von ${esc(String(selected.parent_task_id).slice(-12))}</span>`:'<span class="inherit-badge">ROOT TASK</span>'}<p class="draft-note">Solange der Task nicht als auslaufend markiert ist, erbt der Folge-Task Anzeigename, Beschreibung, kreative Vorgaben, Ausschlüsse und die Style-Referenz. Entwürfe in Textfeldern bleiben bei Auto-Refresh erhalten.</p>
<label class="wide"><span>Anzeigename</span><input id="taskDisplayName" data-draft="taskDisplayName" maxlength="80" value="${esc(selected.display_name||'')}"></label>
<label class="wide"><span>Beschreibung für Landing-Page</span><textarea id="taskDescription" data-draft="taskDescription" rows="3" maxlength="1200">${esc(selected.description||'')}</textarea></label>
<label class="wide"><span>Task-Serie nach Abschluss</span><select id="taskRetiring" data-draft="taskRetiring"><option value="0" ${selected.retire_after_completion?'':'selected'}>FORTLAUFEND · vererbten Folge-Task erzeugen</option><option value="1" ${selected.retire_after_completion?'selected':''}>AUSLAUFEND · keinen vererbten Folge-Task erzeugen</option></select></label>
<div class="task-style-admin"><div class="reference-preview has-image"><img id="taskStylePreview" alt="Style-Referenz des Tasks"></div><div class="task-style-controls"><div class="section-title">NFT STYLE-REFERENZ · ${customStyle?'CUSTOM':'DEFAULT'}</div><p class="small">Dieses Bild definiert <b>wie</b> RIFT für diese Task-Serie gerendert wird. Der globale Character Anchor definiert separat <b>wer</b> RIFT ist. Nutzer sehen diese Vorschau auf der Task-Auswahl.</p><p class="small">${customStyle?`Aktuell: <code>${esc(String(selected.nft_style_reference).slice(0,18))}…</code>`:'Kein eigener Style hochgeladen · es wird das eingebettete Default-Style-Bild verwendet.'}</p><input id="taskStyleFile" type="file" accept="image/jpeg,image/png"><div class="task-config-actions style-actions"><button id="uploadTaskStyle">STYLE HOCHLADEN / ERSETZEN</button>${customStyle?'<button id="clearTaskStyle" class="danger-button">AUF DEFAULT ZURÜCK</button>':''}</div></div></div>
<label class="wide"><span>Kreative Vorgaben für RIFT-Karten dieses Tasks · optional</span><textarea id="taskNFTPrompt" data-draft="taskNFTPrompt" rows="6" maxlength="8000" placeholder="Leer lassen = automatische Theme-/Outfit-/Szenen-Generierung. Optional z.B. Winter, elegante Streetwear, keine Waffen …">${esc(selected.nft_prompt_instructions||'')}</textarea></label>
<label class="wide"><span>Zusätzliche Ausschlüsse · optional</span><textarea id="taskNFTNegative" data-draft="taskNFTNegative" rows="3" maxlength="4000" placeholder="Leer lassen = nur globale RIFT-Regeln. Optional z.B. kein Helm, keine Waffen, kein Schnee …">${esc(selected.nft_negative_prompt||'')}</textarea></label>
<div class="task-config-actions"><button id="saveTaskConfig">TASK-KONFIG SPEICHERN</button></div><div class="task-config-box pipeline-test-box"><div class="section-title">LOKALER PIPELINE-TEST</div><p class="small">Erzeugt eine komplette Testkarte <b>ohne OpenAI-Aufruf</b>. Der vorhandene <code>character_anchor.png</code> dient als Mock-Artwork, die Style-Referenz dieses Tasks als Hintergrund. Kartenlayout, Traits, Dateischreiben und SVG-Ausgabe werden lokal durchgespielt. Der echte Task-/Artifact-Status bleibt unverändert.</p><div class="task-config-actions"><button id="runPipelineTest">TEST-KARTE ERZEUGEN · 0 API-TOKENS</button></div></div></div>
${artifactOwner&&selected.status==='completed'?`<div class="task-config-box ownership-box"><div class="section-title">NFT-EIGENTUM</div><p class="small">Historischer Gewinner bleibt unverändert. Nur der aktuelle Besitzer des Collectibles wird übertragen und der Transfer wird in SQLite protokolliert.</p><div class="small">Aktueller Besitzer: <code>${esc(artifactOwner)}</code> · Ursprung: <b>${esc(selected.artifact_origin||'win')}</b></div><label class="wide"><span>Ziel Client-ID</span><input id="transferTargetClient" data-draft="transferTargetClient" list="adminClientIDs" placeholder="client id"></label><label class="wide"><span>Grund · optional</span><input id="transferReason" data-draft="transferReason" maxlength="500" placeholder="z.B. Support-Transfer"></label><div class="task-config-actions"><button id="transferArtifact" class="danger-button">NFT ÜBERTRAGEN</button></div></div>`:''}
<datalist id="adminClientIDs">${clients.map(c=>`<option value="${esc(c.client_id)}">${Number(c.wins||0)} Wins · ${Number(c.nft_count||0)} NFTs</option>`).join('')}</datalist>
<div class="section-title">AKTIONEN / SCHEDULER</div><label><span>Aktion</span><select id="actionType" data-draft="actionType"><option value="set_range_bits">Zahlenraum ändern</option><option value="set_intervals">Intervalle ändern</option><option value="clear_intervals">Intervalle auf Defaults</option><option value="pause">Pausieren</option><option value="resume">Fortsetzen</option><option value="reroll">Ziel neu würfeln</option><option value="close">Task beenden</option><option value="regenerate_artifact">NFT-Neugenerierung anstoßen</option></select></label><div id="actionPayload"></div>
<label class="wide"><span>Ausführen am</span><div class="schedule-row"><input id="actionAt" data-draft="actionAt" type="datetime-local"><button id="runAction">JETZT</button><button id="scheduleAction">PLANEN</button></div></label><div class="action-warning" id="actionWarning"></div>
<div class="section-title action-history-title">AKTIONSPLAN / AUDIT</div><div class="action-list">${actions.length?actions.map(a=>`<div class="action-item ${esc(a.status)}"><span><b>${esc(actionLabel(a.action_type))}</b><small>${fmtDate(a.execute_at)} · ${esc(payloadText(a))}</small>${a.error?`<small class="danger">${esc(a.error)}</small>`:''}</span><em>${esc(a.status)}</em>${a.status==='pending'?`<button data-cancel-action="${esc(a.id)}">×</button>`:''}</div>`).join(''):'<div class="empty small">Noch keine geplanten Aktionen</div>'}</div></div>`;
@@ -543,9 +549,10 @@ async function runAdmin(){
const uploadStyle=$('uploadTaskStyle');if(uploadStyle)uploadStyle.onclick=async()=>{const file=$('taskStyleFile')?.files?.[0];if(!file){msg('Bitte zuerst ein JPEG- oder PNG-Stylebild auswählen');return}const fd=new FormData();fd.append('file',file,file.name);uploadStyle.disabled=true;uploadStyle.textContent='STYLE WIRD HOCHGELADEN …';try{await api(`/api/admin/tasks/${selected.id}/style-reference`,{method:'PUT',body:fd},true);msg('Task-Style gespeichert · Folge-Task übernimmt ihn');await load(true,true)}catch(e){msg(e.message);uploadStyle.disabled=false;uploadStyle.textContent='STYLE HOCHLADEN / ERSETZEN'}};
const clearStyle=$('clearTaskStyle');if(clearStyle)clearStyle.onclick=async()=>{if(!confirm('Eigenen Task-Style entfernen und wieder den eingebetteten Default-Style verwenden?'))return;try{await api(`/api/admin/tasks/${selected.id}/style-reference`,{method:'DELETE'},true);msg('Task-Style auf Default zurückgesetzt');await load(true,true)}catch(e){msg(e.message)}};
const pipelineTest=$('runPipelineTest');if(pipelineTest)pipelineTest.onclick=async()=>{pipelineTest.disabled=true;pipelineTest.textContent='TEST-KARTE WIRD LOKAL ERZEUGT …';try{const r=await api(`/api/admin/tasks/${selected.id}/pipeline-test`,{method:'POST'},true);const popup=window.open('','_blank');const resp=await fetch(r.url,{credentials:'same-origin'});if(!resp.ok)throw new Error(await responseError(resp,'Testkarte konnte nicht geöffnet werden'));const blob=await resp.blob(),u=URL.createObjectURL(blob);if(popup)popup.location=u;else{const a=document.createElement('a');a.href=u;a.target='_blank';a.click()}setTimeout(()=>URL.revokeObjectURL(u),60000);msg('Lokale Testkarte erzeugt · 0 API-Calls · $0.00')}catch(e){msg(e.message)}finally{pipelineTest.disabled=false;pipelineTest.textContent='TEST-KARTE ERZEUGEN · 0 API-TOKENS'}};
const renderPayload=()=>{const type=$('actionType').value,host=$('actionPayload');if(type==='set_range_bits')host.innerHTML=`<label><span>Task-Zahlenraum (Bit)</span><input id="actionBits" data-draft="actionBits" type="number" min="8" max="128" value="${selected.range_bits}"></label><label><span>Änderungsmodus</span><select id="actionMode" data-draft="actionMode"><option value="preserve">preserve · bestehendes Ziel</option><option value="reroll">reroll · neues Ziel</option></select></label><p class="small">Preserve erhält Seed/Sequenzen und re-skaliert Scores mathematisch. Reroll setzt Scores/Sequenzen zurück.</p>`;else if(type==='set_intervals')host.innerHTML=`<label><span>Server Minimum (s)</span><input id="actionServer" data-draft="actionServer" type="number" min="1" max="3600" value="${selected.guess_min_interval_sec??settings.guess_min_interval_sec}"></label><label><span>Client Submit (s)</span><input id="actionClient" data-draft="actionClient" type="number" min="2" max="7200" value="${selected.client_submit_interval_sec??settings.client_submit_interval_sec}"></label>`;else host.innerHTML=`<p class="small">${type==='reroll'?'Achtung: neues Ziel und neuer öffentlicher Seed; aktuelle Scores werden auf 0 gesetzt.':type==='close'?'Beendet den Task. Der automatisch erzeugte Folge-Task erbt Zahlenraum, Intervalle, Darstellung, RIFT-Prompt und Style-Referenz.':type==='regenerate_artifact'?'Nur für abgeschlossene Tasks: setzt das Artifact wieder auf pending. DONE im Audit bestätigt nur das Einreihen; der Artifact-Status zeigt danach generating, ready oder error.':'Keine weiteren Parameter.'}</p>`};
const transferArtifact=$('transferArtifact');if(transferArtifact)transferArtifact.onclick=async()=>{const target=String($('transferTargetClient')?.value||'').trim(),reason=String($('transferReason')?.value||'').trim();if(!target){msg('Ziel Client-ID fehlt');return}if(!confirm(`NFT von ${artifactOwner.slice(0,18)}… auf ${target.slice(0,18)}… übertragen?\n\nDer historische Gewinner bleibt unverändert.`))return;transferArtifact.disabled=true;try{await api(`/api/admin/tasks/${selected.id}/artifact/transfer`,{method:'POST',body:JSON.stringify({target_client_id:target,reason})},true);msg('NFT-Eigentum übertragen');clearDraftKeys(['transferTargetClient','transferReason']);await load(true,true)}catch(e){msg(e.message)}finally{transferArtifact.disabled=false}};
const renderPayload=()=>{const type=$('actionType').value,host=$('actionPayload');if(type==='set_range_bits')host.innerHTML=`<label><span>Task-Zahlenraum (Bit)</span><input id="actionBits" data-draft="actionBits" type="number" min="8" max="128" value="${selected.range_bits}"></label><label><span>Änderungsmodus</span><select id="actionMode" data-draft="actionMode"><option value="preserve">preserve · bestehendes Ziel</option><option value="reroll">reroll · neues Ziel</option></select></label><p class="small">Preserve erhält Seed/Sequenzen und re-skaliert Scores mathematisch. Reroll setzt Scores/Sequenzen zurück.</p>`;else if(type==='set_intervals')host.innerHTML=`<label><span>Server Minimum (s)</span><input id="actionServer" data-draft="actionServer" type="number" min="1" max="3600" value="${selected.guess_min_interval_sec??settings.guess_min_interval_sec}"></label><label><span>Client Submit (s)</span><input id="actionClient" data-draft="actionClient" type="number" min="2" max="7200" value="${selected.client_submit_interval_sec??settings.client_submit_interval_sec}"></label>`;else host.innerHTML=`<p class="small">${type==='reroll'?'Achtung: neues Ziel und neuer öffentlicher Seed; aktuelle Scores werden auf 0 gesetzt.':type==='close'?(selected.retire_after_completion?'Beendet den Task. AUSLAUFEND ist aktiv: es wird kein vererbter Folge-Task erzeugt.':'Beendet den Task. Der automatisch erzeugte Folge-Task erbt Zahlenraum, Intervalle, Darstellung, RIFT-Prompt und Style-Referenz.'):type==='regenerate_artifact'?'Nur für abgeschlossene Tasks: setzt das Artifact wieder auf pending. DONE im Audit bestätigt nur das Einreihen; der Artifact-Status zeigt danach generating, ready oder error.':'Keine weiteren Parameter.'}</p>`};
const defaultAt=new Date(Date.now()+5*60*1000);defaultAt.setMinutes(defaultAt.getMinutes()-defaultAt.getTimezoneOffset());$('actionAt').value=defaultAt.toISOString().slice(0,16);restoreDraft();renderPayload();restoreDraft();$('actionType').onchange=()=>{renderPayload();restoreDraft();captureDraft()};
$('saveTaskConfig').onclick=async()=>{try{captureDraft();await api(`/api/admin/tasks/${selected.id}/config`,{method:'PUT',body:JSON.stringify({display_name:$('taskDisplayName').value,description:$('taskDescription').value,nft_prompt_instructions:$('taskNFTPrompt').value,nft_negative_prompt:$('taskNFTNegative').value})},true);clearDraftKeys(['taskDisplayName','taskDescription','taskNFTPrompt','taskNFTNegative']);msg('Task-Konfiguration gespeichert · Folge-Task übernimmt sie');await load(true,true)}catch(e){msg(e.message)}};
$('saveTaskConfig').onclick=async()=>{try{captureDraft();await api(`/api/admin/tasks/${selected.id}/config`,{method:'PUT',body:JSON.stringify({display_name:$('taskDisplayName').value,description:$('taskDescription').value,nft_prompt_instructions:$('taskNFTPrompt').value,nft_negative_prompt:$('taskNFTNegative').value,retire_after_completion:$('taskRetiring').value==='1'})},true);clearDraftKeys(['taskDisplayName','taskDescription','taskNFTPrompt','taskNFTNegative']);msg($('taskRetiring').value==='1'?'Task-Konfiguration gespeichert · Task ist AUSLAUFEND':'Task-Konfiguration gespeichert · Folge-Task übernimmt sie');await load(true,true)}catch(e){msg(e.message)}};
const submitAction=async runNow=>{try{captureDraft();const type=$('actionType').value,payload={};if(type==='set_range_bits'){payload.bits=Number($('actionBits').value);payload.mode=$('actionMode').value}else if(type==='set_intervals'){payload.server_min_interval_sec=Number($('actionServer').value);payload.client_submit_interval_sec=Number($('actionClient').value)}const execute_at=runNow?null:new Date($('actionAt').value).toISOString();await api(`/api/admin/tasks/${selected.id}/actions`,{method:'POST',body:JSON.stringify({action_type:type,payload,execute_at})},true);msg(runNow?'Aktion ausgeführt':'Aktion geplant');await load(true,true)}catch(e){msg(e.message)}};
$('runAction').onclick=()=>submitAction(true);$('scheduleAction').onclick=()=>submitAction(false);document.querySelectorAll('[data-cancel-action]').forEach(b=>b.onclick=async()=>{try{await api(`/api/admin/actions/${b.dataset.cancelAction}/cancel`,{method:'POST'},true);await load(true,true)}catch(e){msg(e.message)}});
}
@@ -562,7 +569,7 @@ async function runAdmin(){
function renderSettings(){setActive('tabRuntime',tab==='runtime');setActive('tabTask',tab==='task');setActive('tabArtifact',tab==='artifact');if(tab==='runtime')renderRuntime();else if(tab==='artifact')renderArtifact();else renderTaskControl();restoreDraft()}
function renderMap(){points=Array.isArray(points)?points:[];const min=Number($('adminMinScore')?.value||0),needle=String($('adminClientFilter')?.value||'').trim().toLowerCase(),filtered=points.filter(p=>Number(p.score||0)>=min&&(!needle||String(p.client_id||'').toLowerCase().includes(needle)));$('adminpoints').textContent=`${filtered.length.toLocaleString('de-DE')} / ${points.length.toLocaleString('de-DE')}`;$('selectedtask').textContent=selected?.id||'Task wählen';$('adminbits').textContent=selected?.range_bits??'—';$('winner').textContent=selected?.winner_client_id?`Winner ${shortID(selected.winner_client_id,12)}`:selected?.paused?'PAUSED':'';map.update(filtered,'',+$('adminmaxnodes').value)}
async function refreshSelected(refreshControls=false){if(!selected){points=[];actions=[];renderMap();if(refreshControls)renderSettings();return}try{const [ps,as]=await Promise.all([api(`/api/admin/tasks/${selected.id}/points?limit=100000`,{},true),api(`/api/admin/tasks/${selected.id}/actions?limit=100`,{},true)]);points=Array.isArray(ps)?ps:[];actions=Array.isArray(as)?as:[];renderMap();if(refreshControls)renderSettings()}catch(e){msg(e.message)}}
async function load(keepMessage=false,refreshControls=false){if(loading)return;loading=true;try{const status=$('statusfilter').value,q=$('taskquery').value,dayStart=new Date();dayStart.setHours(0,0,0,0);const [ts,st,ov,pv,pf,au]=await Promise.all([api(`/api/admin/tasks?status=${encodeURIComponent(status)}&q=${encodeURIComponent(q)}&limit=300`,{},true),api('/api/admin/settings',{},true),api('/api/admin/overview',{},true),api('/api/admin/artifact/providers',{},true),api('/api/admin/performance',{},true),api(`/api/admin/artifact/usage?day_start_ms=${dayStart.getTime()}`,{},true)]);tasks=Array.isArray(ts)?ts:[];settings=st||{};providers=pv||{};artifactUsage=au||{};refreshArtifactUsageTelemetry();renderOverview(ov||{});renderPerformance(pf||{});if(selected){selected=tasks.find(t=>t.id===selected.id)||selected}renderTasks();if(selected)await refreshSelected(refreshControls);else if(refreshControls)renderSettings()}catch(e){if(e.status===401){location.reload();return}msg(e.message||'Laden fehlgeschlagen')}finally{loading=false}}
async function load(keepMessage=false,refreshControls=false){if(loading)return;loading=true;try{const status=$('statusfilter').value,q=$('taskquery').value,dayStart=new Date();dayStart.setHours(0,0,0,0);const [ts,st,ov,pv,pf,au,cl]=await Promise.all([api(`/api/admin/tasks?status=${encodeURIComponent(status)}&q=${encodeURIComponent(q)}&limit=300`,{},true),api('/api/admin/settings',{},true),api('/api/admin/overview',{},true),api('/api/admin/artifact/providers',{},true),api('/api/admin/performance',{},true),api(`/api/admin/artifact/usage?day_start_ms=${dayStart.getTime()}`,{},true),api('/api/admin/clients?limit=300',{},true)]);tasks=Array.isArray(ts)?ts:[];clients=Array.isArray(cl)?cl:[];settings=st||{};providers=pv||{};artifactUsage=au||{};refreshArtifactUsageTelemetry();renderOverview(ov||{});renderPerformance(pf||{});if(selected){selected=tasks.find(t=>t.id===selected.id)||selected}renderTasks();if(selected)await refreshSelected(refreshControls);else if(refreshControls)renderSettings()}catch(e){if(e.status===401){location.reload();return}msg(e.message||'Laden fehlgeschlagen')}finally{loading=false}}
async function openTask(t){captureDraft();selected=t;draft.selectedTaskId=t?.id||'';saveDraft();await refreshSelected(true);renderTasks();if(document.documentElement.classList.contains('mobile-mode'))setAdminPanel('map')}
$('filter').onclick=()=>{saveDraft();load()};$('statusfilter').onchange=()=>{saveDraft();load()};$('taskquery').addEventListener('input',saveDraft);$('taskquery').addEventListener('keydown',e=>{if(e.key==='Enter'){saveDraft();load()}});$('adminmaxnodes').oninput=e=>{$('adminmaxvalue').textContent=Number(e.target.value).toLocaleString('de-DE');renderMap()};$('adminMinScore').oninput=renderMap;$('adminClientFilter').oninput=renderMap;
$('adminProximity').onclick=()=>{map.proximityFocus=!map.proximityFocus;$('adminProximity').textContent=map.proximityFocus?'TARGET FIELD':'RAW 3D';setActive('adminProximity',map.proximityFocus)};$('adminRotate').onclick=()=>{map.autoRotate=!map.autoRotate;setActive('adminRotate',map.autoRotate)};$('adminEdges').onclick=()=>{map.edges=!map.edges;setActive('adminEdges',map.edges)};$('adminShells').onclick=()=>{map.shells=!map.shells;setActive('adminShells',map.shells)};$('adminLOD').onclick=()=>{map.lodEnabled=!map.lodEnabled;map.rebuild();setActive('adminLOD',map.lodEnabled)};$('adminGuessFlash').onclick=()=>{adminGuessFlash=!adminGuessFlash;map.guessFlashEnabled=adminGuessFlash;if(!adminGuessFlash)map.guessFlashes=[];setActive('adminGuessFlash',adminGuessFlash);try{localStorage.setItem(adminGuessFlashKey,adminGuessFlash?'1':'0')}catch{}if(adminGuessFlash)openAdminSignalWS();else closeAdminSignalWS()};$('adminEco').onclick=()=>{map.eco=!map.eco;map.resize();setActive('adminEco',map.eco)};$('adminReset').onclick=()=>map.resetView();

View File

@@ -114,3 +114,4 @@ html.mobile-mode .reference-admin-box,html.mobile-mode .task-style-admin{grid-te
.beacon-buttons button{min-width:0;padding:8px 5px;font-size:11px;letter-spacing:1px}
.beacon-buttons button.active{border-color:#fff;box-shadow:0 0 18px rgba(255,255,255,.14);background:rgba(255,255,255,.13)}
.beacon-choice>small{display:block;opacity:.68;line-height:1.35}
.admin-drop-box,.ownership-box{margin-top:12px}.admin-drop-box #adminDropResult{margin-top:8px;color:#8edbb8}.task-facts{grid-template-columns:repeat(4,minmax(0,1fr))}@media(max-width:720px){.task-facts{grid-template-columns:repeat(2,minmax(0,1fr))}}