RC-16
All checks were successful
release-tag / release-image (push) Successful in 3m57s

This commit is contained in:
2026-08-14 14:19:16 +02:00
parent 52d33b25fd
commit 1a56dec079
16 changed files with 765 additions and 33 deletions

View File

@@ -7,3 +7,5 @@ web/dist
*.db-shm
*.db-wal
*.zip
.env.controller

27
.env.controller.example Normal file
View File

@@ -0,0 +1,27 @@
# One file per remote worker host. Copy to .env.controller (gitignored).
SC_ID=worker-host-01
SC_NAME=Worker Host 01
SC_MASTER_URL=http://10.20.0.10:8092
SC_ADVERTISE_URL=http://10.20.0.21:8102
SERVICE_CONTROLLER_SHARED_SECRET=replace-with-the-same-32-plus-char-controller-secret-as-master
SC_CONTROL_ADDR=:8102
SC_ADMIN_ADDR=:8101
SC_ADMIN_USER=admin
SC_ADMIN_PASSWORD=replace-with-a-strong-unique-controller-password
SC_ADMIN_COOKIE_SECURE=auto
SC_MAX_WORKERS=500
SC_MAX_RUNNING_WORKERS=100
SC_HEARTBEAT_INTERVAL=15s
# Worker runtime on this host.
SC_WORKER_NETWORK=bridge
SC_WORKER_REGISTER_URL=http://10.20.0.10:8092/internal/workers/register
SC_GAME_PUBLIC_URL=https://play.example.com
SC_WORKER_IMAGE=registry.example.com/neuralhunt/worker:worker_latest
SC_WORKER_REGISTRY_SERVER=registry.example.com
SC_WORKER_REGISTRY_USERNAME=
SC_WORKER_REGISTRY_PASSWORD=
DOCKER_HOST=unix:///var/run/docker.sock

View File

@@ -24,6 +24,7 @@ ADMIN_COOKIE_SECURE=auto
WS_ALLOWED_ORIGINS=
WS_MAX_USER_CONNECTIONS=5000
WS_MAX_LEADERBOARD_CONNECTIONS=500
WS_MAX_PLACE_CONNECTIONS=1500
# Legacy token-in-query fallback is disabled by default because URLs are logged.
# WS_ALLOW_QUERY_TOKEN=1
@@ -53,6 +54,21 @@ DEFAULT_PRESENCE_TTL_SEC=35
DEFAULT_MAX_NODES=2000
DEFAULT_PUBLIC_SCORE_PRECISION=2
# Neural Place economy. Rewards can come from score progress, actually drawn
# lottery tickets and active signed Hunt time. Delegated worker rewards are
# always routed to the linked owner wallet. Set draw/time points to 0 to disable
# that reward source. Long idle gaps above MAX_GAP are never backfilled.
DEFAULT_PLACE_ENABLED=1
DEFAULT_PLACE_WIDTH=256
DEFAULT_PLACE_HEIGHT=256
DEFAULT_PLACE_POINTS_PER_SCORE=100
DEFAULT_PLACE_DRAW_POINTS=25
DEFAULT_PLACE_DRAW_BEACON_MULTIPLIER=1
DEFAULT_PLACE_TIME_POINTS=20
DEFAULT_PLACE_TIME_INTERVAL_SEC=60
DEFAULT_PLACE_TIME_MAX_GAP_SEC=120
DEFAULT_PLACE_PIXEL_COST=100
# RIFT full-art collection (default). For normal operation you only need these
# two values. Create the global RIFT character anchor once from Admin → ARTIFACT
# (or let the first winner create it as a fallback). Each task can then receive
@@ -72,6 +88,15 @@ OPENAI_BASE_URL=https://api.openai.com
# ARTIFACT_HEIGHT=1536
# ARTIFACT_QUALITY=medium
# ARTIFACT_HTTP_TIMEOUT=4m
# Default rarity distribution for newly generated RIFT collectibles. These
# values seed the runtime settings and must add up to exactly 100.00.
DEFAULT_ARTIFACT_RARITY_COMMON_PCT=58.00
DEFAULT_ARTIFACT_RARITY_UNCOMMON_PCT=29.50
DEFAULT_ARTIFACT_RARITY_RARE_PCT=9.90
DEFAULT_ARTIFACT_RARITY_ULTRA_RARE_PCT=2.25
DEFAULT_ARTIFACT_RARITY_SPECIAL_ILLUSTRATION_PCT=0.35
# ARTIFACT_PROMPT=... # only used by legacy preset/providers
# ARTIFACT_NEGATIVE_PROMPT=... # only used by legacy/local providers
@@ -169,8 +194,46 @@ CS_WORKER_REGISTRY_PASSWORD=
CS_WORKER_ENTRYPOINT=
CS_WORKER_NETWORK=neuralhunt_backend
CS_WORKER_REGISTER_URL=http://customer-service:8092/internal/workers/register
# direct = legacy docker.sock on Customer Service host
# controller = all new workers run on registered remote Service Controllers
# hybrid = prefer Service Controllers; local Docker is fallback / legacy host
CS_WORKER_ORCHESTRATION_MODE=direct
# Separate secret used only between Customer Service (Master) and Service Controllers.
# Required in controller/hybrid mode. Do not reuse JWT_SECRET or CUSTOMER_SERVICE_SHARED_SECRET.
SERVICE_CONTROLLER_SHARED_SECRET=replace-with-another-32-plus-char-random-secret
CS_CONTROLLER_OFFLINE_AFTER=45s
DOCKER_HOST=unix:///var/run/docker.sock
# ─────────────────────────────────────────────────────────────────────────────
# REMOTE SERVICE CONTROLLER (set these on each worker host)
# ─────────────────────────────────────────────────────────────────────────────
# Stable unique ID per host. Changing it creates a new controller identity.
SC_ID=worker-host-01
SC_NAME=Worker Host 01
# Customer Service internal listener reachable over your private VPN/network.
SC_MASTER_URL=http://192.168.16.3:8092
# This controller's private control endpoint as reachable from Customer Service.
SC_ADVERTISE_URL=http://192.168.16.20:8102
SC_CONTROL_ADDR=:8102
SC_ADMIN_ADDR=:8101
SC_ADMIN_USER=admin
SC_ADMIN_PASSWORD=replace-with-strong-controller-admin-password
SC_ADMIN_COOKIE_SECURE=auto
SC_MAX_WORKERS=500
SC_MAX_RUNNING_WORKERS=100
SC_HEARTBEAT_INTERVAL=15s
# Emergency UI image pull defaults. Normal Master commands still carry CS_WORKER_IMAGE.
SC_WORKER_IMAGE=neuralhunt-worker:local
SC_WORKER_REGISTRY_SERVER=
SC_WORKER_REGISTRY_USERNAME=
SC_WORKER_REGISTRY_PASSWORD=
# Optional per-host topology overrides. Recommended for distributed hosts.
SC_WORKER_NETWORK=bridge
# Must be reachable from the worker container. Usually the Customer Service VPN IP.
SC_WORKER_REGISTER_URL=http://192.168.16.3:8092/internal/workers/register
# Must be reachable from the remote host; normally use the public HTTPS game URL.
SC_GAME_PUBLIC_URL=https://play.example.com
# Game endpoints as seen from Customer Service. These localhost defaults make
# `go run ./cmd/customer-service` work when the game server also runs locally.
# docker-compose.yml explicitly overrides them to http://app:8080 / :8081.

2
.gitignore vendored
View File

@@ -7,3 +7,5 @@ web/dist
*.db-wal
*.log
.DS_Store
.env.controller

View File

@@ -1,3 +1,5 @@
> **V4.5 Place Reward Economy:** Neural Place vergütet jetzt zusätzlich tatsächlich gezogene Lotterie-Tipps und aktive, signierte Hunt-Zeit. Draw-Rewards können automatisch mit dem effektiven Beacon-Pfadgewicht multipliziert werden; aktive Zeit wird nur zwischen gültigen Requests innerhalb einer konfigurierbaren Maximalpause aufgebaut, sodass kein Offline-/Idle-Backfill entsteht. Alle Quellen gelten auch für verknüpfte Hosted Worker und landen im Owner-Wallet. Admins können Place-Punkte direkt im RUNTIME-Portal vergeben; Draw-, Zeit- und Admin-Boni besitzen ein dauerhaftes Audit. Details: `V4.5_PLACE_REWARD_ECONOMY.md`.
> **V4.4 Neural Place:** Neural Hunt besitzt jetzt eine globale, r/place-inspirierte Pixel-Leinwand unter `/place`. Echte Score-Steigerungen erzeugen Place-Punkte; Fortschritte verknüpfter Hosted Worker werden dem Owner-Wallet zugerechnet. Placements kosten Punkte statt Zeit-Cooldown und werden live über einen eigenen WebSocket synchronisiert. Wallet, Audit-Historie, Canvas und Placement-History liegen dauerhaft in SQLite. Details: `V4.4_NEURAL_PLACE.md`.
> **V4.2.9 Worker Recovery & Image Update:** Hosted Worker erkennen halb-offene Game-WebSockets per 90s Client-Deadline, erneuern abgelaufene Game-JWTs automatisch mit derselben persistenten P-256-Identity und wiederholen den Wechsel auf einen Folge-Task, bis er verfügbar ist. Hosted Worker fallen bei einem späteren Container-Neustart außerdem von einer inzwischen inaktiven gespeicherten Task-ID auf die serverseitig ausgewählte aktive Task zurück. Neue Worker-Container verwenden `RestartPolicy=unless-stopped`; eine explizite Lease-Sperre kann diese Recovery nicht umgehen. Im privaten Customer Admin kann `CS_WORKER_IMAGE` explizit neu gepullt und ein einzelner oder alle bestehenden Worker unter Beibehaltung ihrer Identity-Volumes neu erzeugt werden. Details: `V4.2.9_WORKER_RECOVERY_IMAGE_UPDATE.md`.
@@ -18,7 +20,7 @@
> **V3.5 Random Guess Lottery:** Optional kann die Zahl der tatsächlich ausgewerteten Tipps pro Zeitfenster begrenzt werden. Im Admin-Tab **RUNTIME** steuern `Lotterie-Zeitfenster (s)` und `Max. gezogene Tipps je Task/Fenster` die Funktion; `0` deaktiviert sie vollständig. Die Lotterie läuft getrennt pro aktivem Task. Alle gültigen, signierten Tipps werden bis zum Ende des Zeitfensters gesammelt. Im normalen Lotterie-Modus werden sie anschließend mit `crypto/rand` gleichberechtigt zufällig gezogen; bei aktiviertem Beacon Hunt übernimmt stattdessen der erst nach Fensterschluss verfügbare drand-Reveal die deterministische gewichtete Ziehung. Nur gezogene Tipps werden gegen das geheime Ziel ausgewertet und können Score/Winner/NFT auslösen. Nicht gezogene Tipps verbrauchen ihre Sequenz, damit beim nächsten Fenster ein neuer deterministischer Tipp entsteht, zählen aber nicht als akzeptierter `guess_count`. Abgebrochene HTTP-Requests verbrauchen keinen Lotterie-Slot. Dadurch wird die mögliche Task-Abschluss-/NFT-Rate gedrosselt, ohne Gewinner oder Styles direkt zu manipulieren.
# Neural Hunt — V4.4 Neural Place + Service Controller
# Neural Hunt — V4.5 Place Reward Economy + Service Controller
> **V3.1 Admin Profile Cleanup:** Im Admin-Tab **RUNTIME** gibt es ein manuelles Bereinigungstool für alte Identitäten. Die Inaktivitätsdauer ist in Stunden/Tagen/Wochen einstellbar. Vor dem Löschen zeigt **PRÜFEN** die Anzahl löschbarer Profile. Gelöscht werden ausschließlich Profile, deren letzte Aktivität älter als die gewählte Grenze ist, die aktuell nicht verbunden sind, niemals Gewinner eines Tasks waren und keine dauerhafte Neural-Place-Ökonomie besitzen. Gewinner, Place-Teilnehmer, verknüpfte Worker/Owner und aktuell verbundene Clients werden geschützt. Beim Löschen werden die per Foreign Key abhängigen `task_points`, `client_unlocks` und `client_task_selection` mit entfernt. WebSocket-Verbindungsaufbau und -ende aktualisieren `clients.last_seen`, damit die Inaktivitätsgrenze tatsächliche Nutzung besser abbildet.

View File

@@ -546,3 +546,20 @@ Zusätzlich zu den bestehenden Tests sollte für Neural Place mindestens Folgend
7. Ein alter Place-Teilnehmer mit verdientem Guthaben oder Placements wird von der Admin-Profilbereinigung nicht gelöscht.
Der Data-Layer enthält dafür `TestPlaceWorkerProgressCreditsOwnerAndFundsPlacements`; der Server-Cleanup enthält `TestProfileCleanupProtectsPlaceParticipant`.
## V4.5 Place Reward Economy
Zusätzlich zu den V4.4-Place-Tests:
1. Ein tatsächlich gezogener Lotterie-Tipp erhält exakt `place_draw_points`; derselbe Task/Revision/Client/Seq-Reward ist idempotent.
2. Im Beacon-Modus erhält ein gezogener Tipp auf dem Boost-Pfad bei aktiviertem `place_draw_beacon_multiplier` exakt `place_draw_points × effektives Beacon-Gewicht`.
3. Ein gezogener Tipp ohne Score-Verbesserung behält seinen Draw-Bonus; eine zusätzliche Score-Verbesserung erzeugt separat den normalen Progress-Reward.
4. Ein verknüpfter Hosted Worker schreibt Draw- und Zeit-Rewards in das Owner-Wallet, bleibt aber als `source_client_id` im Audit sichtbar.
5. Der erste gültige signierte Request startet nur die Aktivzeitmessung. Nach mindestens `place_time_interval_sec` gezählter aktiver Zeit werden `place_time_points` pro vollem Intervall gutgeschrieben.
6. Eine Lücke größer als `place_time_max_gap_sec` wird nicht nachträglich als aktive Zeit vergütet.
7. Doppelte/alte Sequenznummern dürfen die Aktivzeit nicht vorwärts bewegen. Nach einem Task-Reroll wird aufgrund der neuen Task-Revision eine neue Zeit-State-Reihe verwendet.
8. `POST /api/admin/place/points` akzeptiert nur vorhandene Identitäten und positive Gutschriften; Worker-Ziele werden auf den Owner aufgelöst und der Grund wird in `place_bonus_events` protokolliert.
9. `/api/place/me` führt Progress-, Draw-, Zeit- und Admin-Earnings gemeinsam chronologisch auf.
10. Werte `0` für `place_draw_points` bzw. `place_time_points` deaktivieren nur die jeweilige Reward-Quelle, ohne die übrige Place-Ökonomie zu beeinflussen.
Der Data-Layer enthält dafür `TestPlaceDrawTimeAndAdminRewardsCreditDelegatedOwner`. Das Long-Idle-Verhalten der prozesslokalen Aktivzeitmessung wird zusätzlich im Server-Smoke-Test über eine Pause oberhalb von `place_time_max_gap_sec` geprüft.

View File

@@ -1,5 +1,7 @@
# V4.4 — Neural Place
> **Hinweis:** Ab V4.5 wird die Place-Ökonomie zusätzlich durch Draw-, Aktivzeit- und Admin-Rewards erweitert. Siehe `V4.5_PLACE_REWARD_ECONOMY.md`.
Neural Place ergänzt den Hunt um eine gemeinsame, globale Pixel-Leinwand nach dem Grundprinzip von r/place. Anders als bei einem zeitbasierten Cooldown werden Placements ausschließlich durch echten Fortschritt im Hunt finanziert.
## Spielökonomie

View File

@@ -0,0 +1,111 @@
# V4.5 — Place Reward Economy
V4.5 erweitert die Neural-Place-Ökonomie um zusätzliche Spielanreize zwischen seltenen Score-Fortschritten. Ein Pixel besitzt weiterhin keinen Zeit-Cooldown; stattdessen können mehrere nachvollziehbare Hunt-Ereignisse Place-Punkte erzeugen.
## Reward-Quellen
### 1. Score-Fortschritt
Unverändert erzeugt jede dauerhaft gespeicherte persönliche Best-Score-Steigerung Place-Punkte. Standard:
```text
DEFAULT_PLACE_POINTS_PER_SCORE=100
```
`+1,00` Score-Prozentpunkte ergeben damit `100` Place-Punkte.
### 2. Tipp wird tatsächlich gezogen
Wenn die Guess-Lotterie aktiv ist und ein gültiger Tipp am Fensterende wirklich ausgewählt wird, erhält der Spieler unabhängig davon, ob der Tipp anschließend den persönlichen Highscore verbessert, einen Draw-Bonus.
Standard:
```text
DEFAULT_PLACE_DRAW_POINTS=25
DEFAULT_PLACE_DRAW_BEACON_MULTIPLIER=1
```
Bei normaler Lotterie beträgt der Reward damit `25` Punkte. Im Beacon-Hunt-Modus verwendet V4.5 auf Wunsch dasselbe effektive Pfadgewicht wie die gewichtete Ziehung. Beispiel bei `DEFAULT_BEACON_BONUS_WEIGHT=2`:
- gewählter Pfad ist nicht der Boost-Pfad → `25 × 1 = 25` Punkte,
- gewählter Pfad ist der Boost-Pfad → `25 × 2 = 50` Punkte.
`DEFAULT_PLACE_DRAW_BEACON_MULTIPLIER=0` deaktiviert ausschließlich diese Multiplikation. `DEFAULT_PLACE_DRAW_POINTS=0` deaktiviert Draw-Rewards vollständig.
Draw-Rewards sind über `Task + Task-Revision + Client/Worker + Sequenz` idempotent. Ein Retry kann daher denselben gezogenen Tipp nicht doppelt vergüten; nach einem Task-Reroll mit zurückgesetzter Sequenz bleibt die neue Revision davon unabhängig.
### 3. Investierte aktive Hunt-Zeit
Gültige, signierte Hunt-Aktivität mit aktiver Presence baut Spielzeit auf. Standard:
```text
DEFAULT_PLACE_TIME_POINTS=20
DEFAULT_PLACE_TIME_INTERVAL_SEC=60
DEFAULT_PLACE_TIME_MAX_GAP_SEC=120
```
Damit entstehen alle 60 Sekunden aktiver Teilnahme `20` Punkte. Bei Pixelkosten von `100` Punkten kann ein dauerhaft aktiver Spieler allein über Zeit nach ungefähr fünf Minuten einen Pixel finanzieren; Score- und Draw-Rewards kommen zusätzlich dazu.
Die Zeit wird nicht durch einen bloß geöffneten Browser-Tab erzeugt. Gezählt wird nur die Zeit zwischen aufeinanderfolgenden gültigen, signierten Hunt-Requests. Ist die Lücke größer als `DEFAULT_PLACE_TIME_MAX_GAP_SEC`, wird diese Pause nicht angerechnet. Bereits zuvor tatsächlich investierte Restzeit bleibt erhalten, Offline-Zeit wird aber niemals nachträglich aufgefüllt.
`DEFAULT_PLACE_TIME_POINTS=0` deaktiviert Zeit-Rewards.
## Hosted Worker
Für Score-, Draw- und Zeit-Rewards gilt dieselbe `identity_delegations`-Logik. Ist die auslösende Identität ein verknüpfter Hosted Worker, landet die Gutschrift im Wallet des Owners. Die Worker-ID bleibt im Audit als Quelle sichtbar.
Damit können Browser, CLI und mehrere Worker dasselbe Place-Wallet aufbauen, ohne Identitäten zusammenzulegen.
## Admin-Gutschriften
Unter **Admin → RUNTIME → PLACE-PUNKTE MANUELL VERGEBEN** kann ein Admin jetzt:
- eine vorhandene Client- oder Worker-ID auswählen,
- eine positive Punktmenge bis auf `0,001` Punkte genau eingeben,
- optional einen Grund / eine Support-Notiz hinterlegen,
- die Gutschrift sofort ausführen.
Wird eine Worker-ID angegeben, wird auch eine manuelle Gutschrift konsistent auf das Owner-Wallet geroutet.
Die letzten Draw-, Aktivzeit- und Admin-Bonusereignisse werden direkt im Admin-Portal angezeigt.
## Audit und Persistenz
Neu ist `place_bonus_events` als unveränderliches Audit für `draw`, `time` und `admin`, inklusive Quelle, Owner, Punkte, Multiplikator, Einheiten und Detailtext. Das bestehende `place_progress_events` bleibt für exakte Score-Deltas bestehen. Das Spieler-Wallet führt beide Audit-Quellen chronologisch in `recent_earnings` zusammen.
Die noch nicht ausgezahlte Restzeit der aktiven Zeitmessung wird absichtlich nur im Serverprozess gehalten, damit gewöhnliche gültige Hunt-Requests ohne fälliges Zeitintervall keinen zusätzlichen SQLite-Write verursachen. Bei einem Server-Neustart kann dadurch höchstens das aktuell noch unvollständige Zeitintervall verloren gehen; bereits ausgezahlte Zeitpunkte und deren Audit-Ereignisse bleiben dauerhaft in SQLite erhalten.
## Admin-API
Zusätzlich stehen im privaten Control Plane zur Verfügung:
```text
GET /api/admin/place/earnings?limit=50&client_id=<optional>
POST /api/admin/place/points
```
Beispiel für eine manuelle Gutschrift:
```json
{
"target_client_id": "<client-or-worker-id>",
"points": 100,
"reason": "Event-Bonus"
}
```
## Runtime-Konfiguration
Alle relevanten Werte sind unter **Admin → RUNTIME** änderbar:
```text
place_points_per_score
place_draw_points
place_draw_beacon_multiplier
place_time_points
place_time_interval_sec
place_time_max_gap_sec
place_pixel_cost
```
Zusammen mit `beacon_bonus_weight` lässt sich damit genau steuern, wie schnell sich ein Pixel über Fortschritt, gezogene Tipps und investierte Zeit erspielen lässt.

View File

@@ -40,11 +40,15 @@ type PlaceStats struct {
}
type PlaceEarning struct {
Kind string `json:"kind"`
SourceClientID string `json:"source_client_id"`
TaskID string `json:"task_id"`
OldScore float64 `json:"old_score"`
NewScore float64 `json:"new_score"`
OldScore float64 `json:"old_score,omitempty"`
NewScore float64 `json:"new_score,omitempty"`
Points float64 `json:"points"`
Multiplier float64 `json:"multiplier,omitempty"`
Units int64 `json:"units,omitempty"`
Detail string `json:"detail,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
@@ -204,14 +208,21 @@ func (s *Store) PlaceWallet(ctx context.Context, clientID string, pixelCostPoint
}
workers.Close()
rows, err := s.DB.QueryContext(ctx, `SELECT source_client_id,task_id,old_score,new_score,points_milli,created_at FROM place_progress_events WHERE owner_client_id=? ORDER BY created_at DESC,id DESC LIMIT 12`, clientID)
rows, err := s.DB.QueryContext(ctx, `
SELECT kind,source_client_id,task_id,old_score,new_score,points_milli,multiplier,units,detail,created_at FROM (
SELECT 'progress' AS kind,source_client_id,task_id,old_score,new_score,points_milli,1.0 AS multiplier,1 AS units,'' AS detail,created_at,id*2 AS sort_id
FROM place_progress_events WHERE owner_client_id=?
UNION ALL
SELECT kind,source_client_id,task_id,0.0 AS old_score,0.0 AS new_score,points_milli,multiplier,units,detail,created_at,id*2+1 AS sort_id
FROM place_bonus_events WHERE owner_client_id=?
) ORDER BY created_at DESC,sort_id DESC LIMIT 16`, clientID, clientID)
if err != nil {
return out, err
}
for rows.Next() {
var e PlaceEarning
var pointsMilli, createdMS int64
if err := rows.Scan(&e.SourceClientID, &e.TaskID, &e.OldScore, &e.NewScore, &pointsMilli, &createdMS); err != nil {
if err := rows.Scan(&e.Kind, &e.SourceClientID, &e.TaskID, &e.OldScore, &e.NewScore, &pointsMilli, &e.Multiplier, &e.Units, &e.Detail, &createdMS); err != nil {
rows.Close()
return out, err
}

View File

@@ -0,0 +1,210 @@
package data
import (
"context"
"database/sql"
"errors"
"fmt"
"math"
"strings"
"time"
)
// PlaceBonusEvent is an immutable audit entry for non-score Place earnings.
// Progress earnings stay in place_progress_events so the exact score delta is
// retained, while draw/time/admin rewards are recorded here.
type PlaceBonusEvent struct {
Kind string `json:"kind"`
OwnerClientID string `json:"owner_client_id"`
SourceClientID string `json:"source_client_id"`
TaskID string `json:"task_id,omitempty"`
Points float64 `json:"points"`
Multiplier float64 `json:"multiplier"`
Units int64 `json:"units"`
Detail string `json:"detail,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
func resolvePlaceOwnerTx(ctx context.Context, tx *sql.Tx, sourceClientID string) (string, error) {
owner := sourceClientID
var delegatedOwner string
err := tx.QueryRowContext(ctx, `SELECT owner_client_id FROM identity_delegations WHERE worker_client_id=?`, sourceClientID).Scan(&delegatedOwner)
switch {
case err == nil && delegatedOwner != "":
owner = delegatedOwner
case errors.Is(err, sql.ErrNoRows):
case err != nil:
return "", err
}
return owner, nil
}
func creditPlaceBonusTx(ctx context.Context, tx *sql.Tx, eventKey, kind, owner, source, taskID string, pointsMilli int64, multiplier float64, units int64, detail string, nowMS int64) (bool, error) {
if pointsMilli <= 0 {
return false, nil
}
if strings.TrimSpace(eventKey) == "" || strings.TrimSpace(kind) == "" || strings.TrimSpace(owner) == "" || strings.TrimSpace(source) == "" {
return false, fmt.Errorf("invalid place bonus event")
}
if multiplier <= 0 {
multiplier = 1
}
if units < 1 {
units = 1
}
res, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO place_bonus_events(event_key,owner_client_id,source_client_id,task_id,kind,points_milli,multiplier,units,detail,created_at) VALUES(?,?,?,?,?,?,?,?,?,?)`,
eventKey, owner, source, taskID, kind, pointsMilli, multiplier, units, detail, nowMS)
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
if n != 1 {
return false, nil
}
if _, err := tx.ExecContext(ctx, `INSERT INTO place_wallets(client_id,balance_milli,earned_milli,updated_at) VALUES(?,?,?,?) ON CONFLICT(client_id) DO UPDATE SET balance_milli=place_wallets.balance_milli+excluded.balance_milli,earned_milli=place_wallets.earned_milli+excluded.earned_milli,updated_at=excluded.updated_at`, owner, pointsMilli, pointsMilli, nowMS); err != nil {
return false, err
}
return true, nil
}
// PlaceRewardDraw grants points when a lottery ticket is actually selected.
// eventKey is derived from task/source/sequence so retries cannot double-credit.
// beaconWeight is the same effective ticket weight used by the draw itself.
func (s *Store) PlaceRewardDraw(ctx context.Context, taskID, sourceClientID string, taskRevision, seq int64, basePoints int, beaconWeight int, useBeaconMultiplier bool) (string, float64, bool, error) {
if basePoints <= 0 {
return "", 0, false, nil
}
weight := 1
if useBeaconMultiplier && beaconWeight > 1 {
weight = beaconWeight
}
pointsMilli := int64(basePoints) * int64(weight) * 1000
nowMS := time.Now().UTC().UnixMilli()
tx, err := s.DB.BeginTx(ctx, nil)
if err != nil {
return "", 0, false, err
}
defer tx.Rollback()
owner, err := resolvePlaceOwnerTx(ctx, tx, sourceClientID)
if err != nil {
return "", 0, false, err
}
eventKey := fmt.Sprintf("draw|%s|%s|%d|%d", taskID, sourceClientID, taskRevision, seq)
detail := "lottery selected"
if weight > 1 {
detail = fmt.Sprintf("lottery selected · beacon x%d", weight)
}
created, err := creditPlaceBonusTx(ctx, tx, eventKey, "draw", owner, sourceClientID, taskID, pointsMilli, float64(weight), 1, detail, nowMS)
if err != nil {
return "", 0, false, err
}
if err := tx.Commit(); err != nil {
return "", 0, false, err
}
return owner, milliToPoints(pointsMilli), created, nil
}
// PlaceRewardTimeIntervals durably credits one or more active-time intervals.
// The hot-path clock itself is kept in server memory so losing guesses remain
// free of SQLite writes; only an actual payout reaches the database. The event
// key contains task revision and sequence so a retry cannot double-credit.
func (s *Store) PlaceRewardTimeIntervals(ctx context.Context, taskID, sourceClientID string, taskRevision, seq int64, pointsPerInterval int, intervals int64, intervalSec int) (string, float64, bool, error) {
if pointsPerInterval <= 0 || intervals <= 0 || intervalSec <= 0 {
return "", 0, false, nil
}
pointsMilli := intervals * int64(pointsPerInterval) * 1000
nowMS := time.Now().UTC().UnixMilli()
tx, err := s.DB.BeginTx(ctx, nil)
if err != nil {
return "", 0, false, err
}
defer tx.Rollback()
owner, err := resolvePlaceOwnerTx(ctx, tx, sourceClientID)
if err != nil {
return "", 0, false, err
}
eventKey := fmt.Sprintf("time|%s|%s|%d|%d", taskID, sourceClientID, taskRevision, seq)
detail := fmt.Sprintf("%d active interval(s) x %ds", intervals, intervalSec)
created, err := creditPlaceBonusTx(ctx, tx, eventKey, "time", owner, sourceClientID, taskID, pointsMilli, 1, intervals, detail, nowMS)
if err != nil {
return "", 0, false, err
}
if err := tx.Commit(); err != nil {
return "", 0, false, err
}
return owner, milliToPoints(pointsMilli), created, nil
}
// PlaceAdminGrant manually credits a player account. If a hosted worker ID is
// supplied, the reward is intentionally routed to its delegated owner so the
// same wallet semantics apply everywhere.
func (s *Store) PlaceAdminGrant(ctx context.Context, targetClientID string, points float64, reason string) (PlaceBonusEvent, error) {
if strings.TrimSpace(targetClientID) == "" {
return PlaceBonusEvent{}, fmt.Errorf("target client required")
}
if math.IsNaN(points) || math.IsInf(points, 0) || points < 0.001 || points > 1_000_000_000 {
return PlaceBonusEvent{}, fmt.Errorf("points must be 0.001..1000000000")
}
pointsMilli := int64(math.Round(points * 1000))
if pointsMilli < 1 {
return PlaceBonusEvent{}, fmt.Errorf("minimum grant is 0.001 points")
}
reason = strings.TrimSpace(reason)
if len(reason) > 500 {
return PlaceBonusEvent{}, fmt.Errorf("reason too long")
}
now := time.Now().UTC()
nowMS := now.UnixMilli()
tx, err := s.DB.BeginTx(ctx, nil)
if err != nil {
return PlaceBonusEvent{}, err
}
defer tx.Rollback()
var exists int
if err := tx.QueryRowContext(ctx, `SELECT count(*) FROM clients WHERE id=?`, targetClientID).Scan(&exists); err != nil {
return PlaceBonusEvent{}, err
}
if exists != 1 {
return PlaceBonusEvent{}, fmt.Errorf("client not found")
}
owner, err := resolvePlaceOwnerTx(ctx, tx, targetClientID)
if err != nil {
return PlaceBonusEvent{}, err
}
eventKey := NewID("admin_place_")
created, err := creditPlaceBonusTx(ctx, tx, eventKey, "admin", owner, targetClientID, "", pointsMilli, 1, 1, reason, nowMS)
if err != nil {
return PlaceBonusEvent{}, err
}
if !created {
return PlaceBonusEvent{}, fmt.Errorf("admin grant collision")
}
if err := tx.Commit(); err != nil {
return PlaceBonusEvent{}, err
}
return PlaceBonusEvent{Kind: "admin", OwnerClientID: owner, SourceClientID: targetClientID, Points: milliToPoints(pointsMilli), Multiplier: 1, Units: 1, Detail: reason, CreatedAt: now}, nil
}
func (s *Store) PlaceBonusEvents(ctx context.Context, clientID string, limit int) ([]PlaceBonusEvent, error) {
if limit < 1 || limit > 500 {
limit = 100
}
q := `SELECT kind,owner_client_id,source_client_id,task_id,points_milli,multiplier,units,detail,created_at FROM place_bonus_events WHERE (?='' OR owner_client_id=? OR source_client_id=?) ORDER BY created_at DESC,id DESC LIMIT ?`
rows, err := s.DB.QueryContext(ctx, q, clientID, clientID, clientID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]PlaceBonusEvent, 0, limit)
for rows.Next() {
var e PlaceBonusEvent
var pointsMilli, createdMS int64
if err := rows.Scan(&e.Kind, &e.OwnerClientID, &e.SourceClientID, &e.TaskID, &pointsMilli, &e.Multiplier, &e.Units, &e.Detail, &createdMS); err != nil {
return nil, err
}
e.Points = milliToPoints(pointsMilli)
e.CreatedAt = time.UnixMilli(createdMS).UTC()
out = append(out, e)
}
return out, rows.Err()
}

View File

@@ -0,0 +1,92 @@
package data
import (
"context"
"testing"
"neuralhunt/internal/auth"
)
func TestPlaceDrawTimeAndAdminRewardsCreditDelegatedOwner(t *testing.T) {
ctx := context.Background()
db, err := OpenSQLite(ctx, t.TempDir()+"/place-rewards.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"}
for _, id := range []string{"owner", "worker"} {
if err := s.UpsertClient(ctx, id, jwk); err != nil {
t.Fatal(err)
}
}
if err := s.SetIdentityDelegation(ctx, "worker", "owner"); err != nil {
t.Fatal(err)
}
if err := s.EnsureActiveTasks(ctx, 1, 28); err != nil {
t.Fatal(err)
}
tasks, err := s.ActiveTasks(ctx)
if err != nil || len(tasks) != 1 {
t.Fatalf("tasks=%d err=%v", len(tasks), err)
}
task := tasks[0]
owner, points, created, err := s.PlaceRewardDraw(ctx, task.ID, "worker", task.Revision, 7, 25, 3, true)
if err != nil {
t.Fatal(err)
}
if owner != "owner" || points != 75 || !created {
t.Fatalf("draw owner=%q points=%v created=%v", owner, points, created)
}
_, _, created, err = s.PlaceRewardDraw(ctx, task.ID, "worker", task.Revision, 7, 25, 3, true)
if err != nil {
t.Fatal(err)
}
if created {
t.Fatal("duplicate draw reward was credited")
}
owner, points, created, err = s.PlaceRewardTimeIntervals(ctx, task.ID, "worker", task.Revision, 8, 20, 1, 60)
if err != nil {
t.Fatal(err)
}
if owner != "owner" || points != 20 || !created {
t.Fatalf("time reward owner=%q points=%v created=%v", owner, points, created)
}
_, _, created, err = s.PlaceRewardTimeIntervals(ctx, task.ID, "worker", task.Revision, 8, 20, 1, 60)
if err != nil {
t.Fatal(err)
}
if created {
t.Fatal("duplicate time reward was credited")
}
grant, err := s.PlaceAdminGrant(ctx, "worker", 5.5, "support bonus")
if err != nil {
t.Fatal(err)
}
if grant.OwnerClientID != "owner" || grant.SourceClientID != "worker" || grant.Points != 5.5 || grant.Kind != "admin" {
t.Fatalf("grant=%+v", grant)
}
wallet, err := s.PlaceWallet(ctx, "owner", 100)
if err != nil {
t.Fatal(err)
}
if wallet.BalancePoints != 100.5 || wallet.EarnedPoints != 100.5 || wallet.AvailablePixels != 1 {
t.Fatalf("wallet=%+v", wallet)
}
if len(wallet.RecentEarnings) < 3 {
t.Fatalf("expected recent rewards, got %+v", wallet.RecentEarnings)
}
events, err := s.PlaceBonusEvents(ctx, "owner", 10)
if err != nil {
t.Fatal(err)
}
if len(events) != 3 {
t.Fatalf("bonus events=%+v", events)
}
}

View File

@@ -265,3 +265,22 @@ CREATE TABLE IF NOT EXISTS place_progress_events (
);
CREATE INDEX IF NOT EXISTS place_progress_owner_idx ON place_progress_events(owner_client_id,created_at DESC);
CREATE INDEX IF NOT EXISTS place_progress_source_idx ON place_progress_events(source_client_id,created_at DESC);
-- V4.5 Place bonus economy: lottery draw rewards, active-time rewards and
-- manual admin grants. event_key makes externally triggered rewards idempotent.
CREATE TABLE IF NOT EXISTS place_bonus_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_key TEXT NOT NULL UNIQUE,
owner_client_id TEXT NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
source_client_id TEXT NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
task_id TEXT NOT NULL DEFAULT '',
kind TEXT NOT NULL,
points_milli INTEGER NOT NULL,
multiplier REAL NOT NULL DEFAULT 1,
units INTEGER NOT NULL DEFAULT 1,
detail TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS place_bonus_owner_idx ON place_bonus_events(owner_client_id,created_at DESC);
CREATE INDEX IF NOT EXISTS place_bonus_source_idx ON place_bonus_events(source_client_id,created_at DESC);
CREATE INDEX IF NOT EXISTS place_bonus_kind_idx ON place_bonus_events(kind,created_at DESC);

View File

@@ -1,6 +1,7 @@
package server
import (
"context"
"errors"
"net/http"
"strconv"
@@ -10,6 +11,74 @@ import (
wsx "neuralhunt/internal/ws"
)
type placeTimeClock struct {
LastSeq int64
LastAt time.Time
Active time.Duration
}
// rewardPlaceActiveTime keeps the high-frequency activity clock in memory so
// ordinary losing guesses remain free of SQLite writes. Only a completed time
// interval creates a durable wallet/audit transaction. A restart can therefore
// discard at most one partial interval, never already awarded points.
func (s *Server) rewardPlaceActiveTime(ctx context.Context, taskID, sourceClientID string, taskRevision, seq int64, pointsPerInterval, intervalSec, maxGapSec int) error {
if pointsPerInterval <= 0 || intervalSec <= 0 || maxGapSec <= 0 {
return nil
}
now := time.Now().UTC()
interval := time.Duration(intervalSec) * time.Second
maxGap := time.Duration(maxGapSec) * time.Second
key := taskID + "\x00" + sourceClientID + "\x00" + strconv.FormatInt(taskRevision, 10)
s.placeTimeMu.Lock()
if s.placeTimeClocks == nil {
s.placeTimeClocks = make(map[string]placeTimeClock)
}
if s.placeTimeSweep.IsZero() || now.Sub(s.placeTimeSweep) >= 10*time.Minute {
cutoff := now.Add(-24 * time.Hour)
for k, clock := range s.placeTimeClocks {
if clock.LastAt.Before(cutoff) {
delete(s.placeTimeClocks, k)
}
}
s.placeTimeSweep = now
}
clock, ok := s.placeTimeClocks[key]
if !ok {
s.placeTimeClocks[key] = placeTimeClock{LastSeq: seq, LastAt: now}
s.placeTimeMu.Unlock()
return nil
}
if seq <= clock.LastSeq {
s.placeTimeMu.Unlock()
return nil
}
gap := now.Sub(clock.LastAt)
if gap > 0 && gap <= maxGap {
clock.Active += gap
}
intervals := int64(clock.Active / interval)
if intervals > 0 {
clock.Active -= time.Duration(intervals) * interval
}
clock.LastSeq = seq
clock.LastAt = now
s.placeTimeClocks[key] = clock
s.placeTimeMu.Unlock()
if intervals <= 0 {
return nil
}
_, _, created, err := s.store.PlaceRewardTimeIntervals(ctx, taskID, sourceClientID, taskRevision, seq, pointsPerInterval, intervals, intervalSec)
if err != nil {
return err
}
if created {
s.runtime.MarkSQLiteWrite()
}
return nil
}
var placePalette = []string{
"#FFFFFF", "#D4D7D9", "#898D90", "#515252", "#000000", "#FFB470", "#FF8A3D", "#E64B35",
"#FF5C7A", "#E33765", "#B72B73", "#811E9F", "#B44AC0", "#7E57C2", "#5A4FCF", "#3690EA",
@@ -20,14 +89,20 @@ var placePalette = []string{
func (s *Server) placeConfig() map[string]any {
cfg := s.settings.Get()
return map[string]any{
"enabled": cfg.PlaceEnabled == 1,
"width": cfg.PlaceWidth,
"height": cfg.PlaceHeight,
"points_per_score": cfg.PlacePointsPerScore,
"pixel_cost": cfg.PlacePixelCost,
"palette": placePalette,
"economy_explanation": "1.00 Hunt-Score-Fortschritt erzeugt points_per_score Punkte. Ein Placement kostet pixel_cost Punkte.",
"worker_progress_credits": true,
"enabled": cfg.PlaceEnabled == 1,
"width": cfg.PlaceWidth,
"height": cfg.PlaceHeight,
"points_per_score": cfg.PlacePointsPerScore,
"draw_points": cfg.PlaceDrawPoints,
"draw_beacon_multiplier": cfg.PlaceDrawBeaconMultiplier == 1,
"time_points": cfg.PlaceTimePoints,
"time_interval_sec": cfg.PlaceTimeIntervalSec,
"time_max_gap_sec": cfg.PlaceTimeMaxGapSec,
"pixel_cost": cfg.PlacePixelCost,
"palette": placePalette,
"economy_explanation": "Place-Punkte entstehen durch Score-Fortschritt, gezogene Lotterie-Tipps und aktive Hunt-Zeit. Beacon-Boosts können Draw-Punkte mit demselben Pfadgewicht multiplizieren.",
"worker_progress_credits": true,
"worker_bonus_credits_to_owner": true,
}
}
@@ -114,6 +189,40 @@ func (s *Server) placePixel(w http.ResponseWriter, r *http.Request) {
jsonOut(w, 200, map[string]any{"pixel": p, "wallet": wallet})
}
func (s *Server) adminPlaceGrant(w http.ResponseWriter, r *http.Request) {
var in struct {
TargetClientID string `json:"target_client_id"`
Points float64 `json:"points"`
Reason string `json:"reason"`
}
if err := decode(r, &in); err != nil {
jsonOut(w, 400, map[string]string{"error": "bad json: " + err.Error()})
return
}
event, err := s.store.PlaceAdminGrant(r.Context(), in.TargetClientID, in.Points, in.Reason)
if err != nil {
jsonOut(w, 400, map[string]string{"error": err.Error()})
return
}
wallet, walletErr := s.store.PlaceWallet(r.Context(), event.OwnerClientID, s.settings.Get().PlacePixelCost)
if walletErr != nil {
jsonOut(w, 200, map[string]any{"event": event, "wallet": nil, "wallet_refresh_required": true})
return
}
jsonOut(w, 200, map[string]any{"event": event, "wallet": wallet})
}
func (s *Server) adminPlaceEarnings(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
clientID := r.URL.Query().Get("client_id")
events, err := s.store.PlaceBonusEvents(r.Context(), clientID, limit)
if err != nil {
jsonOut(w, 500, map[string]string{"error": err.Error()})
return
}
jsonOut(w, 200, events)
}
func (s *Server) placeWS(w http.ResponseWriter, r *http.Request) {
if !acquireWSCap(&s.placeWSCount, s.maxPlaceWS) {
http.Error(w, "place websocket capacity reached", http.StatusServiceUnavailable)

View File

@@ -55,17 +55,21 @@ type Server struct {
maxUserWS, maxLeaderboardWS, maxPlaceWS int64
userWSCount, leaderboardWSCount, placeWSCount, adminWSCount atomic.Int64
adminSessions sync.Map // sid -> exp unix seconds
placeTimeMu sync.Mutex
placeTimeClocks map[string]placeTimeClock
placeTimeSweep time.Time
}
func New(store *data.Store, a *auth.Manager, sm *settings.Manager, hub *wsx.Hub, runtimeState *rtx.State, artifactDir string, artifactWorker *artifact.Worker) *Server {
s := &Server{
store: store,
auth: a,
settings: sm,
hub: hub,
placeHub: wsx.New(),
runtime: runtimeState,
artifactWorker: artifactWorker,
store: store,
auth: a,
settings: sm,
hub: hub,
placeHub: wsx.New(),
runtime: runtimeState,
placeTimeClocks: make(map[string]placeTimeClock),
artifactWorker: artifactWorker,
lottery: newGuessLottery(func(d beaconDrawAudit) {
_ = store.RecordBeaconDraw(context.Background(), d.TaskID, d.WindowEnd, d.BeaconID, d.BeaconRound, d.Randomness, d.Signature, d.BoostedPath, d.Tickets, d.Selected)
}),
@@ -348,6 +352,8 @@ func (s *Server) Routes() http.Handler {
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/place/earnings", s.adminPlaceEarnings)
r.Post("/api/admin/place/points", s.adminPlaceGrant)
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)
@@ -761,6 +767,15 @@ func (s *Server) guess(w http.ResponseWriter, r *http.Request) {
return
}
// Active-time Place rewards are driven only by valid, signed Hunt activity
// with an active presence. Duplicate sequence numbers are ignored by the
// in-memory activity clock, and long offline gaps are never backfilled.
if cfg.PlaceEnabled == 1 && cfg.PlaceTimePoints > 0 {
if err := s.rewardPlaceActiveTime(r.Context(), t.ID, c.ClientID, t.Revision, in.Seq, cfg.PlaceTimePoints, cfg.PlaceTimeIntervalSec, cfg.PlaceTimeMaxGapSec); err != nil {
log.Printf("place active-time reward %s/%s seq=%d: %v", t.ID, c.ClientID, in.Seq, err)
}
}
var draw lotteryResult
if cfg.GuessLotteryMaxAccepted > 0 {
drawResult, drawErr := s.lottery.enter(r.Context(), t.ID, c.ClientID, in.Seq, in.BeaconPath, time.Duration(cfg.GuessLotteryWindowSec)*time.Second, cfg.GuessLotteryMaxAccepted, cfg.BeaconHuntEnabled == 1, cfg.BeaconBonusWeight)
@@ -797,6 +812,20 @@ func (s *Server) guess(w http.ResponseWriter, r *http.Request) {
return
}
t = fresh
if draw.Selected {
rewardCfg := s.settings.Get()
if rewardCfg.PlaceEnabled == 1 && rewardCfg.PlaceDrawPoints > 0 {
weight := draw.Weight
if weight < 1 {
weight = 1
}
if _, _, created, err := s.store.PlaceRewardDraw(r.Context(), t.ID, c.ClientID, t.Revision, in.Seq, rewardCfg.PlaceDrawPoints, weight, rewardCfg.PlaceDrawBeaconMultiplier == 1); err != nil {
log.Printf("place draw reward %s/%s seq=%d: %v", t.ID, c.ClientID, in.Seq, err)
} else if created {
s.runtime.MarkSQLiteWrite()
}
}
}
if !draw.Selected {
next, skipErr := s.runtime.SkipLottery(t.Task, c.ClientID, in.Seq, minInterval)
if skipErr != nil {
@@ -1544,7 +1573,9 @@ func (s *Server) adminClients(w http.ResponseWriter, r *http.Request) {
}
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
(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,
COALESCE((SELECT d.owner_client_id FROM identity_delegations d WHERE d.worker_client_id=c.id),c.id) AS place_owner_client_id,
COALESCE((SELECT pw.balance_milli FROM place_wallets pw WHERE pw.client_id=COALESCE((SELECT d2.owner_client_id FROM identity_delegations d2 WHERE d2.worker_client_id=c.id),c.id)),0) AS place_balance_milli
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()})
@@ -1556,11 +1587,13 @@ func (s *Server) adminClients(w http.ResponseWriter, r *http.Request) {
var id string
var lastSeen int64
var wins, nfts int
if err := rows.Scan(&id, &lastSeen, &wins, &nfts); err != nil {
var placeOwner string
var placeBalanceMilli int64
if err := rows.Scan(&id, &lastSeen, &wins, &nfts, &placeOwner, &placeBalanceMilli); 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})
out = append(out, map[string]any{"client_id": id, "last_seen": lastSeen, "wins": wins, "nft_count": nfts, "place_owner_client_id": placeOwner, "place_balance_points": float64(placeBalanceMilli) / 1000.0})
}
jsonOut(w, 200, out)
}

View File

@@ -35,6 +35,11 @@ type Runtime struct {
PlaceWidth int `json:"place_width"`
PlaceHeight int `json:"place_height"`
PlacePointsPerScore int `json:"place_points_per_score"`
PlaceDrawPoints int `json:"place_draw_points"`
PlaceDrawBeaconMultiplier int `json:"place_draw_beacon_multiplier"`
PlaceTimePoints int `json:"place_time_points"`
PlaceTimeIntervalSec int `json:"place_time_interval_sec"`
PlaceTimeMaxGapSec int `json:"place_time_max_gap_sec"`
PlacePixelCost int `json:"place_pixel_cost"`
ArtifactPreset string `json:"artifact_preset"`
ArtifactProvider string `json:"artifact_provider"`
@@ -106,6 +111,11 @@ func Defaults() Runtime {
PlaceWidth: envInt("DEFAULT_PLACE_WIDTH", 256),
PlaceHeight: envInt("DEFAULT_PLACE_HEIGHT", 256),
PlacePointsPerScore: envInt("DEFAULT_PLACE_POINTS_PER_SCORE", 100),
PlaceDrawPoints: envInt("DEFAULT_PLACE_DRAW_POINTS", 25),
PlaceDrawBeaconMultiplier: envInt("DEFAULT_PLACE_DRAW_BEACON_MULTIPLIER", 1),
PlaceTimePoints: envInt("DEFAULT_PLACE_TIME_POINTS", 20),
PlaceTimeIntervalSec: envInt("DEFAULT_PLACE_TIME_INTERVAL_SEC", 60),
PlaceTimeMaxGapSec: envInt("DEFAULT_PLACE_TIME_MAX_GAP_SEC", 120),
PlacePixelCost: envInt("DEFAULT_PLACE_PIXEL_COST", 100),
ArtifactPreset: strings.ToLower(envString("ARTIFACT_PRESET", "raccoon_full_art_v1")),
ArtifactProvider: strings.ToLower(envString("ARTIFACT_PROVIDER", "openai")),
@@ -303,6 +313,21 @@ func Validate(v Runtime) error {
if v.PlacePointsPerScore < 1 || v.PlacePointsPerScore > 1000000 {
return fmt.Errorf("place_points_per_score must be 1..1000000")
}
if v.PlaceDrawPoints < 0 || v.PlaceDrawPoints > 100000000 {
return fmt.Errorf("place_draw_points must be 0..100000000")
}
if v.PlaceDrawBeaconMultiplier < 0 || v.PlaceDrawBeaconMultiplier > 1 {
return fmt.Errorf("place_draw_beacon_multiplier must be 0 or 1")
}
if v.PlaceTimePoints < 0 || v.PlaceTimePoints > 100000000 {
return fmt.Errorf("place_time_points must be 0..100000000")
}
if v.PlaceTimeIntervalSec < 5 || v.PlaceTimeIntervalSec > 86400 {
return fmt.Errorf("place_time_interval_sec must be 5..86400")
}
if v.PlaceTimeMaxGapSec < 5 || v.PlaceTimeMaxGapSec > 86400 {
return fmt.Errorf("place_time_max_gap_sec must be 5..86400")
}
if v.PlacePixelCost < 1 || v.PlacePixelCost > 100000000 {
return fmt.Errorf("place_pixel_cost must be 1..100000000")
}

View File

@@ -387,7 +387,7 @@ async function runUser(){
if(e.code==='task_config_changed')await refreshTaskConfig(true);
nextGuessAt=Date.now()+1500;status(e.code==='presence_required'?'Live-Verbindung wird automatisch wiederhergestellt …':'Client wird automatisch synchronisiert …')
}
async function submit(){if(!task||submitting)return;if(!wsReady()){scheduleWSReconnect();nextGuessAt=Date.now()+1000;status('Live-Verbindung wird wiederhergestellt …');return}submitting=true;status('signiert Tipp …','thinking');try{const current=await api('/api/tasks/current');if(current.id!==task.id){await showLanding();return}task=current;syncBeacon();if(task.paused){status('Task pausiert');nextGuessAt=0;return}const seq=task.next_seq,guess=await deterministicGuess(task.id,task.public_seed,cid,seq,task.range_bits),beaconOn=Number(task.beacon_hunt_enabled||0)===1&&Number(task.guess_lottery_max_accepted||0)>0,msg=beaconOn?`guess|${task.id}|${seq}|${guess}|${beaconPath}`:`guess|${task.id}|${seq}|${guess}`,signature=await sign(msg);if(Number(task.guess_lottery_max_accepted||0)>0)status(beaconOn?`Beacon ${beaconPath} committed · wartet auf externen Draw`:`wartet auf Losziehung · max. ${Number(task.guess_lottery_max_accepted).toLocaleString('de-DE')} Tipps / ${Number(task.guess_lottery_window_sec||60)}s`,'thinking');const correct=await api(`/api/tasks/${task.id}/guess`,{method:'POST',body:JSON.stringify({seq,guess,signature,beacon_path:beaconOn?beaconPath:''})});nextGuessAt=Date.now()+Math.max(1,task.client_submit_interval_sec)*1000;if(beaconOn){try{const d=await api(`/api/public/beacon/${encodeURIComponent(task.id)}/latest`);$('beaconLast').textContent=`Dein Pfad ${beaconPath} · Boost ${d.boosted_path} · drand #${d.beacon_round}`;status(correct?'Treffer — Task gelöst!':`Draw ${d.boosted_path} · Tipp gezogen & geprüft`,correct?'researching':'living')}catch{status(correct?'Treffer — Task gelöst!':'Beacon-Tipp gezogen & geprüft',correct?'researching':'living')}}else status(correct?'Treffer — Task gelöst!':Number(task.guess_lottery_max_accepted||0)>0?'Tipp gezogen & geprüft':'Tipp akzeptiert',correct?'researching':'living');await Promise.all([refreshMe(),refreshLeaders()]);if(correct)setTimeout(()=>showLanding(),1300)}catch(e){if(e.status===409){await recover409(e);return}nextGuessAt=Date.now()+Math.max(2,task?.client_submit_interval_sec||11)*1000;if(e.code==='identity_warmup'){const wait=Math.max(1,Number(e.data?.retry_after_sec||task?.client_submit_interval_sec||15));nextGuessAt=Date.now()+wait*1000;status(`Anti-Sybil-Wartezeit · ${wait}s`,'living');return}if(e.code==='lottery_not_selected'){if(e.data?.boosted_path&&$('beaconLast'))$('beaconLast').textContent=`Dein Pfad ${e.data.chosen_path} · Boost ${e.data.boosted_path} · Gewicht ×${e.data.weight||1} · drand #${e.data.beacon_round}`;status('Tipp diesmal nicht gezogen · nächstes Los folgt','living');return}if(e.code==='beacon_unavailable'){status('Randomness Beacon nicht erreichbar · kein Tipp ausgewertet','living');return}if(e.code==='lottery_full'){status('Losfenster voll · nächster Versuch folgt','living');return}status(e.message||'Tipp fehlgeschlagen')}finally{submitting=false}}
async function submit(){if(!task||submitting)return;if(!wsReady()){scheduleWSReconnect();nextGuessAt=Date.now()+1000;status('Live-Verbindung wird wiederhergestellt …');return}submitting=true;status('signiert Tipp …','thinking');try{const current=await api('/api/tasks/current');if(current.id!==task.id){await showLanding();return}task=current;syncBeacon();if(task.paused){status('Task pausiert');nextGuessAt=0;return}const seq=task.next_seq,guess=await deterministicGuess(task.id,task.public_seed,cid,seq,task.range_bits),beaconOn=Number(task.beacon_hunt_enabled||0)===1&&Number(task.guess_lottery_max_accepted||0)>0,msg=beaconOn?`guess|${task.id}|${seq}|${guess}|${beaconPath}`:`guess|${task.id}|${seq}|${guess}`,signature=await sign(msg);if(Number(task.guess_lottery_max_accepted||0)>0)status(beaconOn?`Beacon ${beaconPath} committed · wartet auf externen Draw`:`wartet auf Losziehung · max. ${Number(task.guess_lottery_max_accepted).toLocaleString('de-DE')} Tipps / ${Number(task.guess_lottery_window_sec||60)}s`,'thinking');const correct=await api(`/api/tasks/${task.id}/guess`,{method:'POST',body:JSON.stringify({seq,guess,signature,beacon_path:beaconOn?beaconPath:''})});nextGuessAt=Date.now()+Math.max(1,task.client_submit_interval_sec)*1000;if(beaconOn){try{const d=await api(`/api/public/beacon/${encodeURIComponent(task.id)}/latest`);$('beaconLast').textContent=`Dein Pfad ${beaconPath} · Boost ${d.boosted_path} · drand #${d.beacon_round}`;status(correct?'Treffer — Task gelöst!':`Draw ${d.boosted_path} · Tipp gezogen & geprüft`,correct?'researching':'living')}catch{status(correct?'Treffer — Task gelöst!':'Beacon-Tipp gezogen & geprüft',correct?'researching':'living')}}else status(correct?'Treffer — Task gelöst!':Number(task.guess_lottery_max_accepted||0)>0?'Tipp gezogen & geprüft':'Tipp akzeptiert',correct?'researching':'living');lastPlaceRefresh=0;await Promise.all([refreshMe(),refreshLeaders()]);if(correct)setTimeout(()=>showLanding(),1300)}catch(e){if(e.status===409){await recover409(e);return}nextGuessAt=Date.now()+Math.max(2,task?.client_submit_interval_sec||11)*1000;if(e.code==='identity_warmup'){const wait=Math.max(1,Number(e.data?.retry_after_sec||task?.client_submit_interval_sec||15));nextGuessAt=Date.now()+wait*1000;status(`Anti-Sybil-Wartezeit · ${wait}s`,'living');return}if(e.code==='lottery_not_selected'){if(e.data?.boosted_path&&$('beaconLast'))$('beaconLast').textContent=`Dein Pfad ${e.data.chosen_path} · Boost ${e.data.boosted_path} · Gewicht ×${e.data.weight||1} · drand #${e.data.beacon_round}`;status('Tipp diesmal nicht gezogen · nächstes Los folgt','living');lastPlaceRefresh=0;await refreshMe();return}if(e.code==='beacon_unavailable'){status('Randomness Beacon nicht erreichbar · kein Tipp ausgewertet','living');return}if(e.code==='lottery_full'){status('Losfenster voll · nächster Versuch folgt','living');return}status(e.message||'Tipp fehlgeschlagen')}finally{submitting=false}}
function openWS(force=false){if(!task||$('taskLanding').classList.contains('visible'))return;clearWSReconnect();if(ws&&(ws.readyState===WebSocket.OPEN||ws.readyState===WebSocket.CONNECTING)){if(!force)return;const old=ws;old._plannedClose=true;try{old.close(1000,'reconnect')}catch{}}const proto=location.protocol==='https:'?'wss':'ws',mx=+$('maxnodes').value||task.default_max_nodes||2000,socket=new WebSocket(`${proto}://${location.host}/api/ws?max_nodes=${encodeURIComponent(mx)}`,['neuralhunt.v1',`nh-auth.${getToken()}`]);ws=socket;socket.onopen=()=>{if(ws!==socket)return;wsBackoff=500;status(task?.paused?'Task pausiert':'verbunden')};socket.onmessage=async ev=>{if(ws!==socket)return;const e=JSON.parse(ev.data);if(e.type==='snapshot'){points=Array.isArray(e.data)?e.data:[];render()}else if(e.type==='point'){const p=e.data,i=points.findIndex(x=>x.client_id===p.client_id);if(i<0)points.push(p);else points[i]=p;render()}else if(e.type==='points'){for(const p of (Array.isArray(e.data)?e.data:[])){const i=points.findIndex(x=>x.client_id===p.client_id);if(i<0)points.push(p);else points[i]=p}render()}else if(e.type==='task_changed'){await refreshTaskConfig(true);await Promise.all([refreshMe(),refreshLeaders()])}else if(e.type==='task_completed'){status('Task abgeschlossen — Folge-Task ist bereit','researching');setTimeout(()=>showLanding(),1300)}};socket.onclose=e=>{if(ws===socket)ws=null;if(!socket._plannedClose&&task&&!$('taskLanding').classList.contains('visible')){status('Live-Verbindung unterbrochen · verbinde automatisch neu …');scheduleWSReconnect()}};socket.onerror=()=>{if(!socket._plannedClose&&ws===socket)status('WebSocket-Fehler · Reconnect folgt automatisch')}}
async function enterTask(taskID){if(landingBusy)return;landingBusy=true;try{await stopTaskSession();task=await api('/api/tasks/select',{method:'POST',body:JSON.stringify({task_id:taskID})});$('taskLanding').classList.remove('visible');syncBeacon();$('taskid').textContent=task.display_name?shortID(task.display_name,16):shortID(task.id.slice(-12),12);let max=+$('maxnodes').value||clamp(Number(task.default_max_nodes||2000),100,25000);if(document.documentElement.classList.contains('mobile-mode'))max=Math.min(max,1000);$('maxnodes').value=max;$('maxnodesvalue').textContent=max.toLocaleString('de-DE');points=await api(`/api/tasks/${task.id}/points?limit=${Math.min(10000,Math.max(300,max*3))}`);points=Array.isArray(points)?points:[];render();await Promise.all([refreshMe(),refreshLeaders()]);syncMobile();openWS();status(task.paused?'Task pausiert':`${task.range_bits} Bit · verbunden`);nextGuessAt=task.paused?0:Date.now()+Math.max(1,task.client_submit_interval_sec)*1000;scheduler=setInterval(()=>{if(task&&!task.paused&&nextGuessAt&&Date.now()>=nextGuessAt)submit()},300);countdownTimer=setInterval(countdown,250);countdown()}catch(e){status(e.message||'Task konnte nicht gestartet werden');$('taskLanding').classList.add('visible')}finally{landingBusy=false}}
try{const id=await ensureIdentity();cid=await clientId(id.publicJwk);$('cid').textContent=cid;$('landingCid').textContent=cid;await ensureSession();syncMobile();await Promise.all([refreshLeaders()]);await showLanding()}catch(e){status(e.message||'Startfehler');$('taskCards').innerHTML=`<div class="task-card-empty glass"><b>Startfehler</b><span>${esc(e.message||'')}</span></div>`}
@@ -455,16 +455,16 @@ function placeShell(){
<div class="place-kicker">DEIN PLACE WALLET</div>
<div class="place-balance"><small>PUNKTE</small><strong id="placePoints">0</strong><span><b id="placePixelsAvailable">0</b> Pixel verfügbar</span></div>
<div class="place-wallet-grid"><span><small>ERHALTEN</small><b id="placeEarned">0</b></span><span><small>AUSGEGEBEN</small><b id="placeSpent">0</b></span><span><small>PLACEMENTS</small><b id="placePlacements">0</b></span><span><small>AKTUELL DEINE</small><b id="placeOwned">0</b></span></div>
<div class="place-economy"><span class="eyebrow">ÖKONOMIE</span><p id="placeEconomy">Hunt-Fortschritt erzeugt Place-Punkte.</p><div><b id="placeRate">—</b><small>Punkte pro +1,00 %</small></div><div><b id="placeCost">—</b><small>Punkte pro Pixel</small></div></div>
<div class="place-economy"><span class="eyebrow">ÖKONOMIE</span><p id="placeEconomy">Hunt-Aktivität erzeugt Place-Punkte.</p><div><b id="placeRate">—</b><small>Punkte pro +1,00 %</small></div><div><b id="placeDrawRate">—</b><small>Basis je gezogenem Tipp</small></div><div><b id="placeTimeRate">—</b><small>Punkte je Aktivzeit-Intervall</small></div><div><b id="placeCost">—</b><small>Punkte pro Pixel</small></div></div>
<div class="place-workers"><div class="place-section-head"><span>VERKNÜPFTE WORKER</span><small id="placeWorkerCount">0</small></div><div id="placeWorkerList" class="place-worker-list"><span class="empty small">keine</span></div></div>
<div class="place-earnings"><div class="place-section-head"><span>LETZTE FORTSCHRITTE</span><small>HUNT → PLACE</small></div><div id="placeEarnings" class="place-earning-list"></div></div>
<div class="place-earnings"><div class="place-section-head"><span>LETZTE REWARDS</span><small>HUNT → PLACE</small></div><div id="placeEarnings" class="place-earning-list"></div></div>
</aside>
<section class="place-stage glass">
<div class="place-canvas-toolbar"><div><span class="eyebrow">NEURAL PLACE</span><b id="placeCoords">Pixel wählen</b></div><div class="place-canvas-stats"><span><b id="placeFilled">0</b> belegt</span><span><b id="placeTotal">0</b> Placements</span><span><b id="placeParticipants">0</b> Artists</span></div><div class="place-tools"><button id="placeZoomOut"></button><button id="placeFit">FIT</button><button id="placeZoomIn"></button></div></div>
<div id="placeViewport" class="place-viewport"><canvas id="placeCanvas"></canvas><div id="placeHover" class="place-hover hidden"></div><div class="place-hint">Scroll = Zoom · Ziehen = Pan · Klick = Pixel wählen</div></div>
<div class="place-compose"><div><span class="eyebrow">FARBE</span><div id="placePalette" class="place-palette"></div></div><div class="place-selection"><small>AUSWAHL</small><b id="placeSelection">—</b><span id="placeSelectionOwner">Wähle einen Pixel auf der Leinwand.</span></div><button id="placeSubmit" disabled>PIXEL SETZEN <span id="placeSubmitCost"></span></button></div>
</section>
<aside class="place-side place-right glass"><div class="place-section-head"><span>LIVE FEED</span><small id="placeRevision">REV 0</small></div><div id="placeFeed" class="place-feed"></div><div class="place-rules"><span class="eyebrow">REGEL</span><p>Kein Zeit-Cooldown. Die knappe Ressource ist echter Hunt-Fortschritt. Eigene Fortschritte und Fortschritte deiner verknüpften Worker landen im selben Wallet.</p></div></aside>
<aside class="place-side place-right glass"><div class="place-section-head"><span>LIVE FEED</span><small id="placeRevision">REV 0</small></div><div id="placeFeed" class="place-feed"></div><div class="place-rules"><span class="eyebrow">REGEL</span><p>Kein Pixel-Cooldown. Punkte kommen aus Fortschritt, tatsächlich gezogenen Tipps und aktiver Hunt-Zeit. Beacon-Boosts können den Draw-Bonus erhöhen. Verknüpfte Worker zahlen in dasselbe Owner-Wallet ein.</p></div></aside>
</main>
<div id="placeToast" class="place-toast hidden"></div>`;
}
@@ -477,7 +477,7 @@ async function runPlace(){
const fmtPoints=v=>Number(v||0).toLocaleString('de-DE',{maximumFractionDigits:3});
const toast=(text,bad=false)=>{const el=$('placeToast');el.textContent=text;el.classList.toggle('bad',!!bad);el.classList.remove('hidden');clearTimeout(toastTimer);toastTimer=setTimeout(()=>el.classList.add('hidden'),2600)};
async function ensurePlaceSession(){const id=await ensureIdentity();cid=await clientId(id.publicJwk);if(!getToken()){const x=await loginIdentity();setToken(x.token);return}try{const x=await api('/api/place/me');if(x?.wallet?.client_id!==cid)throw Object.assign(new Error('identity mismatch'),{status:401})}catch(e){if(e.status!==401)throw e;clearToken();const x=await loginIdentity();setToken(x.token)}}
function renderWallet(){if(!wallet||!config)return;$('placePoints').textContent=fmtPoints(wallet.balance_points);$('placePixelsAvailable').textContent=Number(wallet.available_pixels||0).toLocaleString('de-DE');$('placeEarned').textContent=fmtPoints(wallet.earned_points);$('placeSpent').textContent=fmtPoints(wallet.spent_points);$('placePlacements').textContent=Number(wallet.placements||0).toLocaleString('de-DE');$('placeOwned').textContent=Number(wallet.owned_pixels||0).toLocaleString('de-DE');$('placeRate').textContent=fmtPoints(config.points_per_score);$('placeCost').textContent=fmtPoints(config.pixel_cost);$('placeSubmitCost').textContent=`· ${fmtPoints(config.pixel_cost)} P`;$('placeEconomy').textContent=`+1,00 % Hunt-Fortschritt = ${fmtPoints(config.points_per_score)} Punkte. Ein Pixel kostet ${fmtPoints(config.pixel_cost)} Punkte.`;const workers=wallet.linked_workers||[];$('placeWorkerCount').textContent=workers.length;$('placeWorkerList').innerHTML=workers.length?workers.map(x=>`<code title="${esc(x)}">${esc(shortID(x,18))}</code>`).join(''):'<span class="empty small">Keine Worker verknüpft</span>';$('placeEarnings').innerHTML=(wallet.recent_earnings||[]).map(e=>{const worker=e.source_client_id!==cid;return `<div class="place-earning"><span class="place-source ${worker?'worker':''}">${worker?'WORKER':'DU'}</span><div><b>+${fmtPoints(e.points)} P</b><small>${Number(e.old_score||0).toFixed(2)}${Number(e.new_score||0).toFixed(2)} % · ${esc(shortID(e.task_id?.slice(-10),10))}</small></div></div>`}).join('')||'<span class="empty small">Noch kein vergüteter Fortschritt.</span>';updateSubmit()}
function renderWallet(){if(!wallet||!config)return;$('placePoints').textContent=fmtPoints(wallet.balance_points);$('placePixelsAvailable').textContent=Number(wallet.available_pixels||0).toLocaleString('de-DE');$('placeEarned').textContent=fmtPoints(wallet.earned_points);$('placeSpent').textContent=fmtPoints(wallet.spent_points);$('placePlacements').textContent=Number(wallet.placements||0).toLocaleString('de-DE');$('placeOwned').textContent=Number(wallet.owned_pixels||0).toLocaleString('de-DE');$('placeRate').textContent=fmtPoints(config.points_per_score);$('placeDrawRate').textContent=fmtPoints(config.draw_points);$('placeTimeRate').textContent=`${fmtPoints(config.time_points)} / ${Number(config.time_interval_sec||0)}s`;$('placeCost').textContent=fmtPoints(config.pixel_cost);$('placeSubmitCost').textContent=`· ${fmtPoints(config.pixel_cost)} P`;const beaconText=config.draw_beacon_multiplier?` · Beacon-Boost multipliziert Draw-Punkte mit dem Pfadgewicht`:'',timeText=Number(config.time_points||0)>0?` · ${fmtPoints(config.time_points)} P je ${Number(config.time_interval_sec||0)}s aktive Hunt-Zeit`:'';$('placeEconomy').textContent=`+1,00 % Fortschritt = ${fmtPoints(config.points_per_score)} P · gezogen = ${fmtPoints(config.draw_points)} P Basis${beaconText}${timeText} · Pixel = ${fmtPoints(config.pixel_cost)} P.`;const workers=wallet.linked_workers||[];$('placeWorkerCount').textContent=workers.length;$('placeWorkerList').innerHTML=workers.length?workers.map(x=>`<code title="${esc(x)}">${esc(shortID(x,18))}</code>`).join(''):'<span class="empty small">Keine Worker verknüpft</span>';$('placeEarnings').innerHTML=(wallet.recent_earnings||[]).map(e=>{const worker=e.source_client_id!==cid,kind=String(e.kind||'progress'),tag=kind==='progress'?'FORTSCHRITT':kind==='draw'?'GEZOGEN':kind==='time'?'AKTIVZEIT':kind==='admin'?'ADMIN':kind.toUpperCase();let detail='';if(kind==='progress')detail=`${Number(e.old_score||0).toFixed(2)}${Number(e.new_score||0).toFixed(2)} %`;else if(kind==='draw')detail=`Lotterie gezogen${Number(e.multiplier||1)>1?` · Beacon ×${Number(e.multiplier).toLocaleString('de-DE')}`:''}`;else if(kind==='time')detail=`${Number(e.units||1)} Aktivzeit-Intervall${Number(e.units||1)===1?'':'e'}`;else detail=String(e.detail||'Manuelle Gutschrift');if(e.task_id)detail+=` · ${shortID(String(e.task_id).slice(-10),10)}`;return `<div class="place-earning"><span class="place-source ${worker?'worker':''}">${esc(tag)}</span><div><b>+${fmtPoints(e.points)} P</b><small>${esc(detail)}${worker?` · Worker ${esc(shortID(e.source_client_id,12))}`:''}</small></div></div>`}).join('')||'<span class="empty small">Noch keine Place-Rewards.</span>';updateSubmit()}
async function refreshWallet(){try{const x=await api('/api/place/me');config=x.config||config;wallet=x.wallet||wallet;renderWallet()}catch(e){if(e.status===401)return;}}
function renderPalette(){$('placePalette').innerHTML=palette.map((c,i)=>`<button class="place-swatch ${i===selectedColor?'active':''}" data-place-color="${i}" style="--swatch:${c}" aria-label="Farbe ${i+1}"><i></i></button>`).join('');document.querySelectorAll('[data-place-color]').forEach(b=>b.onclick=()=>{selectedColor=Number(b.dataset.placeColor);localStorage.setItem('neuralhunt.place.color',String(selectedColor));renderPalette();draw();updateSubmit()})}
function initBoard(list){pixels=new Uint8Array(width*height);pixels.fill(255);meta=new Map();filledCount=0;off.width=width;off.height=height;offctx.imageSmoothingEnabled=false;offctx.fillStyle='#f5f5f5';offctx.fillRect(0,0,width,height);for(const p of (list||[]))applyPixel(p,false);fit();draw()}
@@ -522,12 +522,13 @@ 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,clients=[],poll=null,tab=['runtime','task','artifact'].includes(draft.tab)?draft.tab:'runtime',loading=false,adminDropWatchIDs=[],adminDropWatchTimer=null;
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=[],placeBonusEvents=[],poll=null,tab=['runtime','task','artifact'].includes(draft.tab)?draft.tab:'runtime',loading=false,adminDropWatchIDs=[],adminDropWatchTimer=null;
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();
const runtimeKeys=['guess_min_interval_sec','client_submit_interval_sec','guess_lottery_window_sec','guess_lottery_max_accepted','beacon_hunt_enabled','beacon_bonus_weight','sybil_pow_bits','sybil_warmup_sec','openai_max_calls_1h','openai_max_calls_24h','openai_max_cost_24h_usd','openai_budget_reserve_usd','task_range_bits','active_task_count','presence_ttl_sec','default_max_nodes','public_score_precision','place_enabled','place_width','place_height','place_points_per_score','place_pixel_cost'];
const labels={guess_min_interval_sec:'Server-Tippintervall (s)',client_submit_interval_sec:'Client-Tippintervall (s)',guess_lottery_window_sec:'Lotterie-Zeitfenster (s)',guess_lottery_max_accepted:'Max. gezogene Tipps je Task/Fenster (0 = aus)',beacon_hunt_enabled:'Beacon Hunt (0 = aus, 1 = an)',beacon_bonus_weight:'Beacon Treffer-Gewicht (110)',sybil_pow_bits:'Anti-Sybil Proof-of-Work (Bit, 0 = aus)',sybil_warmup_sec:'Neue Identität Wartezeit (s)',openai_max_calls_1h:'OpenAI max. Bild-Calls / 1h (0 = aus)',openai_max_calls_24h:'OpenAI max. Bild-Calls / 24h (0 = aus)',openai_max_cost_24h_usd:'OpenAI max. geschätzte Kosten / 24h USD (0 = aus)',openai_budget_reserve_usd:'OpenAI Sicherheitsreserve pro nächstem Call USD',task_range_bits:'Default Zahlenraum (Bit)',active_task_count:'Parallele aktive Tasks',presence_ttl_sec:'Presence TTL (s)',default_max_nodes:'Default Max Nodes',public_score_precision:'Öffentliche Score-Präzision',place_enabled:'Neural Place (0 = aus, 1 = an)',place_width:'Place Breite (Pixel)',place_height:'Place Höhe (Pixel)',place_points_per_score:'Place-Punkte je +1,00 Score',place_pixel_cost:'Place-Kosten je Pixel'};
const runtimeKeys=['guess_min_interval_sec','client_submit_interval_sec','guess_lottery_window_sec','guess_lottery_max_accepted','beacon_hunt_enabled','beacon_bonus_weight','sybil_pow_bits','sybil_warmup_sec','openai_max_calls_1h','openai_max_calls_24h','openai_max_cost_24h_usd','openai_budget_reserve_usd','task_range_bits','active_task_count','presence_ttl_sec','default_max_nodes','public_score_precision','place_enabled','place_width','place_height','place_points_per_score','place_draw_points','place_draw_beacon_multiplier','place_time_points','place_time_interval_sec','place_time_max_gap_sec','place_pixel_cost'];
const labels={guess_min_interval_sec:'Server-Tippintervall (s)',client_submit_interval_sec:'Client-Tippintervall (s)',guess_lottery_window_sec:'Lotterie-Zeitfenster (s)',guess_lottery_max_accepted:'Max. gezogene Tipps je Task/Fenster (0 = aus)',beacon_hunt_enabled:'Beacon Hunt (0 = aus, 1 = an)',beacon_bonus_weight:'Beacon Treffer-Gewicht (110)',sybil_pow_bits:'Anti-Sybil Proof-of-Work (Bit, 0 = aus)',sybil_warmup_sec:'Neue Identität Wartezeit (s)',openai_max_calls_1h:'OpenAI max. Bild-Calls / 1h (0 = aus)',openai_max_calls_24h:'OpenAI max. Bild-Calls / 24h (0 = aus)',openai_max_cost_24h_usd:'OpenAI max. geschätzte Kosten / 24h USD (0 = aus)',openai_budget_reserve_usd:'OpenAI Sicherheitsreserve pro nächstem Call USD',task_range_bits:'Default Zahlenraum (Bit)',active_task_count:'Parallele aktive Tasks',presence_ttl_sec:'Presence TTL (s)',default_max_nodes:'Default Max Nodes',public_score_precision:'Öffentliche Score-Präzision',place_enabled:'Neural Place (0 = aus, 1 = an)',place_width:'Place Breite (Pixel)',place_height:'Place Höhe (Pixel)',place_points_per_score:'Place-Punkte je +1,00 Score',place_draw_points:'Place-Punkte je gezogenem Tipp (0 = aus)',place_draw_beacon_multiplier:'Draw-Bonus × Beacon-Pfadgewicht (0/1)',place_time_points:'Place-Punkte je Aktivzeit-Intervall (0 = aus)',place_time_interval_sec:'Aktivzeit-Intervall (s)',place_time_max_gap_sec:'Max. Aktivitätslücke, die zählt (s)',place_pixel_cost:'Place-Kosten je Pixel'};
const fmtPlacePoints=v=>Number(v||0).toLocaleString('de-DE',{maximumFractionDigits:3});
const msg=s=>$('adminstatus').textContent=s;
const renderAdminDropWatch=items=>{const host=$('adminDropResult');if(!host)return;const rows=Array.isArray(items)?items:[];if(!rows.length){host.textContent=adminDropWatchIDs.length?'Queue-Einträge werden gesucht …':'';return}const order={error:0,generating:1,pending:2,ready:3};rows.sort((a,b)=>(order[a.artifact_status]??9)-(order[b.artifact_status]??9));host.innerHTML=`<div class="admin-drop-watch">${rows.map(x=>{const st=String(x.artifact_status||'unknown'),err=String(x.artifact_error||'').trim(),id=String(x.task_id||'');let info=st==='pending'?'wartet auf Artifact-Worker':st==='generating'?'Bild wird gerade erzeugt':st==='ready'?'Collectible fertig':st==='error'?(err||'Generierung fehlgeschlagen'):st;return `<div class="admin-drop-watch-row ${esc(st)}"><b>${esc(st.toUpperCase())}</b><code>${esc(id.slice(-14))}</code>${(x.artifact_rarity||x.artifact_rarity_override)?`<small>${esc(x.artifact_rarity||x.artifact_rarity_override)}</small>`:''}<span title="${esc(err)}">${esc(info.length>180?info.slice(0,179)+'…':info)}</span>${st==='ready'?`<button type="button" data-drop-open="${esc(id)}">ÖFFNEN</button>`:''}</div>`}).join('')}</div>`;host.querySelectorAll('[data-drop-open]').forEach(b=>b.onclick=()=>openAdminFile(b.dataset.dropOpen,'artifact'))};
const refreshAdminDropWatch=async()=>{clearTimeout(adminDropWatchTimer);adminDropWatchTimer=null;if(!adminDropWatchIDs.length)return;try{const rows=await api(`/api/admin/artifacts/status?ids=${encodeURIComponent(adminDropWatchIDs.join(','))}`,{},true);renderAdminDropWatch(rows);const active=(rows||[]).some(x=>x.artifact_status==='pending'||x.artifact_status==='generating');if(active){adminDropWatchTimer=setTimeout(refreshAdminDropWatch,2000)}else{adminDropWatchIDs=[];setTimeout(()=>load(true,false),0)}}catch(e){const host=$('adminDropResult');if(host)host.textContent='Queue-Status konnte nicht geladen werden: '+(e.message||e);adminDropWatchTimer=setTimeout(refreshAdminDropWatch,4000)}};
@@ -547,11 +548,17 @@ async function runAdmin(){
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.artifact_origin==='admin_drop'?`ADMIN DROP${(t.artifact_rarity||t.artifact_rarity_override)?' · '+esc(t.artifact_rarity||t.artifact_rarity_override):''}`: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"><b>Neural Place:</b> Bei aktiviertem Place-Modus ist der Score ein Prozentwert von 0100. Jede echte Highscore-Steigerung erzeugt <code>Place-Punkte je +1,00 Score</code>; verknüpfte Hosted Worker schreiben dieselben Punkte dem Owner-Wallet gut. Ein Placement auch das Übermalen eines belegten Feldes kostet <code>Place-Kosten je Pixel</code>. Breite/Höhe können erweitert oder nur so weit verkleinert werden, dass kein vorhandener Pixel abgeschnitten wird; offene Place-Seiten sollten nach einer Größenänderung neu geladen werden.</p><p class="small">Die übrigen Defaults gelten global. Task-spezifische Intervalle und Zahlenräume steuerst du im Tab „Task Actions“.</p></div>
$('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"><b>Neural Place:</b> Punkte können aus drei automatischen Quellen kommen: echter Highscore-Fortschritt, ein <b>tatsächlich gezogener</b> Lotterie-Tipp und aktive Hunt-Zeit. Der Draw-Bonus kann mit demselben Beacon-Pfadgewicht multipliziert werden, das auch die Ziehung beeinflusst. Aktivzeit zählt nur zwischen gültigen signierten Requests; Pausen über <code>Max. Aktivitätslücke</code> werden nicht nachvergütet. Verknüpfte Hosted Worker schreiben alle diese Rewards dem Owner-Wallet gut. Ein Placement auch das Übermalen kostet <code>Place-Kosten je Pixel</code>.</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 place-admin-grant"><div class="section-title">PLACE-PUNKTE MANUELL VERGEBEN</div><p class="small">Gutschriften werden sofort dem Place-Wallet gutgeschrieben und dauerhaft auditiert. Wird eine verknüpfte Worker-ID gewählt, landet die Gutschrift konsistent im Wallet des Owners.</p>
<label class="wide"><span>Ziel Client-/Worker-ID</span><input id="placeGrantTarget" data-draft="placeGrantTarget" list="placeGrantClientIDs" placeholder="Client-ID"></label><datalist id="placeGrantClientIDs">${clients.map(c=>`<option value="${esc(c.client_id)}">${fmtPlacePoints(c.place_balance_points||0)} P${c.place_owner_client_id&&c.place_owner_client_id!==c.client_id?` · → Owner ${esc(shortID(c.place_owner_client_id,12))}`:''} · ${Number(c.wins||0)} Wins · ${Number(c.nft_count||0)} NFTs</option>`).join('')}</datalist>
<label><span>Punkte</span><input id="placeGrantPoints" data-draft="placeGrantPoints" type="number" min="0.001" max="1000000000" step="0.001" value="100"></label><label class="wide"><span>Grund / Notiz</span><input id="placeGrantReason" data-draft="placeGrantReason" maxlength="500" placeholder="z.B. Event-Bonus, Support-Korrektur"></label>
<div class="task-config-actions"><button id="grantPlacePoints">PUNKTE GUTSCHREIBEN</button></div><div class="section-title action-history-title">LETZTE PLACE-BONUS-EVENTS</div><div class="action-list">${placeBonusEvents.length?placeBonusEvents.slice(0,16).map(e=>`<div class="action-item done"><span><b>${esc(String(e.kind||'bonus').toUpperCase())} · +${fmtPlacePoints(e.points)} P</b><small>${fmtDate(e.created_at)} · ${esc(shortID(e.owner_client_id||'',16))}${e.source_client_id&&e.source_client_id!==e.owner_client_id?` ← Worker ${esc(shortID(e.source_client_id,14))}`:''}${Number(e.multiplier||1)>1?` · ×${Number(e.multiplier).toLocaleString('de-DE')}`:''}</small>${e.detail?`<small>${esc(e.detail)}</small>`:''}</span><em>DONE</em></div>`).join(''):'<div class="empty small">Noch keine Draw-, Aktivzeit- oder Admin-Boni.</div>'}</div>
</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>, <b>niemals Gewinner</b> eines Tasks und <b>keine Neural-Place-Teilnehmer</b> sind. Gewinner, verknüpfte Worker/Owner und Identitäten mit verdientem Place-Wert oder Placements werden unabhängig vom Alter geschützt. Zugehörige Hunt-Punkte, Unlocks und Task-Auswahl werden bei löschbaren Profilen mit entfernt.</p>
<div class="cleanup-controls"><label><span>Inaktiv seit mindestens</span><input id="profileCleanupValue" data-draft="profileCleanupValue" type="number" min="1" step="1" value="30"></label><label><span>Einheit</span><select id="profileCleanupUnit" data-draft="profileCleanupUnit"><option value="hours">Stunden</option><option value="days" selected>Tage</option><option value="weeks">Wochen</option></select></label></div>
<div class="cleanup-actions"><button id="previewProfileCleanup">PRÜFEN</button><button id="runProfileCleanup" class="danger-button">PROFILE LÖSCHEN</button></div><div id="profileCleanupResult" class="cleanup-result">Noch nicht geprüft.</div>
</div>`;$('savesettings').style.display='inline-block';$('ensuretasks').style.display='inline-block';
$('grantPlacePoints').onclick=async()=>{const target=String($('placeGrantTarget')?.value||'').trim(),points=Number($('placeGrantPoints')?.value||0),reason=String($('placeGrantReason')?.value||'').trim();if(!target){msg('Ziel Client-/Worker-ID fehlt');return}if(!Number.isFinite(points)||points<=0){msg('Punkte müssen größer als 0 sein');return}const b=$('grantPlacePoints');b.disabled=true;try{const out=await api('/api/admin/place/points',{method:'POST',body:JSON.stringify({target_client_id:target,points,reason})},true);const credited=out?.event?.owner_client_id||target;msg(`+${fmtPlacePoints(out?.event?.points||points)} Place-Punkte an ${shortID(credited,18)} gutgeschrieben`);clearDraftKeys(['placeGrantTarget','placeGrantPoints','placeGrantReason']);await load(true,true)}catch(e){msg(e.message)}finally{if(document.body.contains(b))b.disabled=false}};
const cleanupSeconds=()=>{const v=Math.max(1,Number($('profileCleanupValue')?.value||0)),unit=$('profileCleanupUnit')?.value||'days',factor=unit==='hours'?3600:unit==='weeks'?7*86400:86400;return Math.round(v*factor)};
const showCleanup=p=>{const box=$('profileCleanupResult');if(!box)return;const eligible=Number(p?.eligible||0),wins=Number(p?.protected_winners||0),place=Number(p?.protected_place||0),online=Number(p?.protected_connected||0),cutoff=p?.cutoff_ms?fmtDate(p.cutoff_ms):'—';box.innerHTML=`<b>${eligible.toLocaleString('de-DE')} löschbar</b><span>· ${wins.toLocaleString('de-DE')} alte Gewinner · ${place.toLocaleString('de-DE')} Place-Teilnehmer · ${online.toLocaleString('de-DE')} aktuell verbundene Accounts geschützt</span><small>Grenze: letzte Aktivität vor ${esc(cutoff)}</small>`;};
const previewCleanup=async()=>{try{captureDraft();const seconds=cleanupSeconds(),p=await api(`/api/admin/profiles/cleanup-preview?inactive_for_seconds=${seconds}`,{},true);showCleanup(p);return p}catch(e){msg(e.message);throw e}};
@@ -636,7 +643,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,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 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,pbe]=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),api('/api/admin/place/earnings?limit=50',{},true)]);tasks=Array.isArray(ts)?ts:[];clients=Array.isArray(cl)?cl:[];placeBonusEvents=Array.isArray(pbe)?pbe:[];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();