diff --git a/.dockerignore b/.dockerignore index 6c9cce7..b1cdd81 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,3 +7,5 @@ web/dist *.db-shm *.db-wal *.zip + +.env.controller diff --git a/.env.controller.example b/.env.controller.example new file mode 100644 index 0000000..bfa5f14 --- /dev/null +++ b/.env.controller.example @@ -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 diff --git a/.env.example b/.env.example index 29a272b..e5251ff 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/.gitignore b/.gitignore index 6aa3723..fd7def7 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ web/dist *.db-wal *.log .DS_Store + +.env.controller diff --git a/README.md b/README.md index 2a09d4f..7e25942 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/TESTING.md b/TESTING.md index 23f27f4..89e0a4e 100644 --- a/TESTING.md +++ b/TESTING.md @@ -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. diff --git a/V4.4_NEURAL_PLACE.md b/V4.4_NEURAL_PLACE.md index 2a6bfad..751a2cb 100644 --- a/V4.4_NEURAL_PLACE.md +++ b/V4.4_NEURAL_PLACE.md @@ -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 diff --git a/V4.5_PLACE_REWARD_ECONOMY.md b/V4.5_PLACE_REWARD_ECONOMY.md new file mode 100644 index 0000000..9694613 --- /dev/null +++ b/V4.5_PLACE_REWARD_ECONOMY.md @@ -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= +POST /api/admin/place/points +``` + +Beispiel für eine manuelle Gutschrift: + +```json +{ + "target_client_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. diff --git a/internal/data/place.go b/internal/data/place.go index c6caf7b..a795dcb 100644 --- a/internal/data/place.go +++ b/internal/data/place.go @@ -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 } diff --git a/internal/data/place_rewards.go b/internal/data/place_rewards.go new file mode 100644 index 0000000..d125a4a --- /dev/null +++ b/internal/data/place_rewards.go @@ -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() +} diff --git a/internal/data/place_rewards_test.go b/internal/data/place_rewards_test.go new file mode 100644 index 0000000..f06d580 --- /dev/null +++ b/internal/data/place_rewards_test.go @@ -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) + } +} diff --git a/internal/data/schema.sql b/internal/data/schema.sql index 5b47063..9a68c06 100644 --- a/internal/data/schema.sql +++ b/internal/data/schema.sql @@ -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); diff --git a/internal/server/place.go b/internal/server/place.go index 0e96a17..beecec6 100644 --- a/internal/server/place.go +++ b/internal/server/place.go @@ -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) diff --git a/internal/server/server.go b/internal/server/server.go index 6098578..4d29f1e 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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) } diff --git a/internal/settings/settings.go b/internal/settings/settings.go index 8e746b5..9af3118 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -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") } diff --git a/internal/webui/dist/app.js b/internal/webui/dist/app.js index 9a880f7..e5d0d6a 100644 --- a/internal/webui/dist/app.js +++ b/internal/webui/dist/app.js @@ -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=`
Startfehler${esc(e.message||'')}
`} @@ -455,16 +455,16 @@ function placeShell(){
DEIN PLACE WALLET
PUNKTE00 Pixel verfügbar
ERHALTEN0AUSGEGEBEN0PLACEMENTS0AKTUELL DEINE0
-
ÖKONOMIE

Hunt-Fortschritt erzeugt Place-Punkte.

Punkte pro +1,00 %
Punkte pro Pixel
+
ÖKONOMIE

Hunt-Aktivität erzeugt Place-Punkte.

Punkte pro +1,00 %
Basis je gezogenem Tipp
Punkte je Aktivzeit-Intervall
Punkte pro Pixel
VERKNÜPFTE WORKER0
keine
-
LETZTE FORTSCHRITTEHUNT → PLACE
+
LETZTE REWARDSHUNT → PLACE
NEURAL PLACEPixel wählen
0 belegt0 Placements0 Artists
Scroll = Zoom · Ziehen = Pan · Klick = Pixel wählen
FARBE
AUSWAHLWähle einen Pixel auf der Leinwand.
- + `; } @@ -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=>`${esc(shortID(x,18))}`).join(''):'Keine Worker verknüpft';$('placeEarnings').innerHTML=(wallet.recent_earnings||[]).map(e=>{const worker=e.source_client_id!==cid;return `
${worker?'WORKER':'DU'}
+${fmtPoints(e.points)} P${Number(e.old_score||0).toFixed(2)} → ${Number(e.new_score||0).toFixed(2)} % · ${esc(shortID(e.task_id?.slice(-10),10))}
`}).join('')||'Noch kein vergüteter Fortschritt.';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=>`${esc(shortID(x,18))}`).join(''):'Keine Worker verknüpft';$('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 `
${esc(tag)}
+${fmtPoints(e.points)} P${esc(detail)}${worker?` · Worker ${esc(shortID(e.source_client_id,12))}`:''}
`}).join('')||'Noch keine Place-Rewards.';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)=>``).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 (1–10)',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 (1–10)',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=`
${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 `
${esc(st.toUpperCase())}${esc(id.slice(-14))}${(x.artifact_rarity||x.artifact_rarity_override)?`${esc(x.artifact_rarity||x.artifact_rarity_override)}`:''}${esc(info.length>180?info.slice(0,179)+'…':info)}${st==='ready'?``:''}
`}).join('')}
`;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 ``}).join(''):'
Keine Tasks
';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=`
GLOBAL RUNTIME
${runtimeKeys.map(k=>``).join('')}

Die Tipp-Lotterie gilt getrennt pro aktivem Task. Bei einem Wert > 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. 0 = Lotterie aus. Änderungen greifen für neu beginnende Fenster.

Beacon Hunt: 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.

Anti-Sybil: 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.

OpenAI Circuit Breaker: 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.

Neural Place: Bei aktiviertem Place-Modus ist der Score ein Prozentwert von 0–100. Jede echte Highscore-Steigerung erzeugt Place-Punkte je +1,00 Score; verknüpfte Hosted Worker schreiben dieselben Punkte dem Owner-Wallet gut. Ein Placement – auch das Übermalen eines belegten Feldes – kostet Place-Kosten je Pixel. 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.

Die übrigen Defaults gelten global. Task-spezifische Intervalle und Zahlenräume steuerst du im Tab „Task Actions“.

+ $('settingfields').innerHTML=`
GLOBAL RUNTIME
${runtimeKeys.map(k=>``).join('')}

Die Tipp-Lotterie gilt getrennt pro aktivem Task. Bei einem Wert > 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. 0 = Lotterie aus. Änderungen greifen für neu beginnende Fenster.

Beacon Hunt: 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.

Anti-Sybil: 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.

OpenAI Circuit Breaker: 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.

Neural Place: Punkte können aus drei automatischen Quellen kommen: echter Highscore-Fortschritt, ein tatsächlich gezogener 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 Max. Aktivitätslücke werden nicht nachvergütet. Verknüpfte Hosted Worker schreiben alle diese Rewards dem Owner-Wallet gut. Ein Placement – auch das Übermalen – kostet Place-Kosten je Pixel.

Die übrigen Defaults gelten global. Task-spezifische Intervalle und Zahlenräume steuerst du im Tab „Task Actions“.

+
PLACE-PUNKTE MANUELL VERGEBEN

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.

+ ${clients.map(c=>``).join('')} + +
LETZTE PLACE-BONUS-EVENTS
${placeBonusEvents.length?placeBonusEvents.slice(0,16).map(e=>`
${esc(String(e.kind||'bonus').toUpperCase())} · +${fmtPlacePoints(e.points)} P${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')}`:''}${e.detail?`${esc(e.detail)}`:''}DONE
`).join(''):'
Noch keine Draw-, Aktivzeit- oder Admin-Boni.
'}
+
ALTE PROFILE BEREINIGEN

Löscht ausschließlich Accounts, die seit mindestens X Zeit inaktiv, aktuell nicht verbunden, niemals Gewinner eines Tasks und keine Neural-Place-Teilnehmer 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.

Noch nicht geprüft.
`;$('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=`${eligible.toLocaleString('de-DE')} löschbar· ${wins.toLocaleString('de-DE')} alte Gewinner · ${place.toLocaleString('de-DE')} Place-Teilnehmer · ${online.toLocaleString('de-DE')} aktuell verbundene Accounts geschütztGrenze: letzte Aktivität vor ${esc(cutoff)}`;}; 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();