RC-16
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
+15 -4
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
}
+210
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()
}
+92
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)
}
}
+19
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);
+117 -8
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)
+43 -10
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)
}
+25
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")
}
+17 -10
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 (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=`<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 0–100. 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();