From 52d33b25fdfe66059dde20226e3342d91cf92b55 Mon Sep 17 00:00:00 2001 From: groot Date: Fri, 14 Aug 2026 10:13:57 +0200 Subject: [PATCH] RC-15-PLACE --- README.md | 7 +- TESTING.md | 14 + V4.4_NEURAL_PLACE.md | 95 +++++++ internal/data/place.go | 325 ++++++++++++++++++++++++ internal/data/place_test.go | 107 ++++++++ internal/data/schema.sql | 57 +++++ internal/data/store.go | 39 ++- internal/server/place.go | 159 ++++++++++++ internal/server/profile_cleanup_test.go | 39 +++ internal/server/server.go | 81 ++++-- internal/settings/settings.go | 22 ++ internal/webui/dist/app.js | 93 +++++-- internal/webui/dist/styles.css | 13 + 13 files changed, 1008 insertions(+), 43 deletions(-) create mode 100644 V4.4_NEURAL_PLACE.md create mode 100644 internal/data/place.go create mode 100644 internal/data/place_test.go create mode 100644 internal/server/place.go diff --git a/README.md b/README.md index 947fdfa..2a09d4f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +> **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`. > **V4.2.8 Admin Control & Ownership:** Customer-Admins können das Worker-Limit pro Benutzer aufheben und Worker direkt zuordnen. Server-Admins können Task-Serien als AUSLAUFEND markieren, Collectible-Eigentum ohne Umschreiben der historischen Gewinner-Provenienz übertragen und zufällige ADMIN-DROP-NFTs für bestehende Neural-Hunt-Identitäten erzeugen. Details: `V4.2.8_ADMIN_CONTROL_OWNERSHIP.md`. @@ -16,10 +18,10 @@ > **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.1 Split Images + Beacon Hunt + Hosted PrePaid Service +# Neural Hunt — V4.4 Neural Place + 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 und die niemals Gewinner eines Tasks waren. Gewinner werden immer geschützt; aktuell verbundene Clients ebenfalls. 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. +> **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. > **V2.8 RIFT Task-Styles:** RIFT-Identität und Rendering-Stil sind jetzt sauber getrennt. `/data/artifacts/_collection/character_anchor.png` ist ein globaler, neutraler Identity-Lock für den Waschbären RIFT und kann im Admin-Tab **ARTIFACT** einmalig manuell erzeugt und geprüft werden. Jeder Task kann im Admin-Tab **TASK ACTIONS** ein eigenes JPEG-/PNG-Style-Referenzbild erhalten; Nutzer sehen dieses Stylebild bereits in der Task-Auswahl und wählen damit indirekt die gewünschte NFT-Art. Bei jeder RIFT-Karte sendet Neural Hunt **Image 1 = Character Anchor** und **Image 2 = Task Style Reference** an die Images Edit API. Ohne eigenen Task-Style bleibt `internal/artifact/assets/style_reference.jpg` nur noch der Default-Fallback. Folge-Tasks erben ihren Style. `medium` bleibt Standard und die OpenAI-Usage-/Kosten-KPIs aus V2.7 bleiben erhalten. @@ -62,6 +64,7 @@ Danach: - Client: `http://localhost:8080/` - Echtzeit-Leaderboard: `http://localhost:8080/leaderboard` +- Neural Place: `http://localhost:8080/place` - Admin (private listener): `http://localhost:8081/admin` ### V4.1: drei Docker-Images diff --git a/TESTING.md b/TESTING.md index 1f8736f..23f27f4 100644 --- a/TESTING.md +++ b/TESTING.md @@ -532,3 +532,17 @@ For production/open registration, do not rely on PoW as a one-human/one-account 8. Stop the controller process and wait longer than `CS_CONTROLLER_OFFLINE_AFTER`; Master admin must show `OFFLINE` and must not assign new workers there. 9. Use the controller emergency UI on `:8101` over VPN to stop/restart a local managed worker and to execute `ALLE STOPPEN`. 10. Verify direct mode still works unchanged with `CS_WORKER_ORCHESTRATION_MODE=direct` and local docker.sock. + +## V4.4 Neural Place + +Zusätzlich zu den bestehenden Tests sollte für Neural Place mindestens Folgendes geprüft werden: + +1. Eine echte Best-Score-Steigerung erzeugt gemäß `place_points_per_score` Place-Punkte; ein schlechterer Tipp erzeugt keine Punkte. +2. Eine Score-Steigerung eines `identity_delegations`-Workers erhöht ausschließlich das Wallet des verknüpften Owners; `place_progress_events.source_client_id` bleibt die Worker-ID. +3. Zwei Placements ziehen zweimal `place_pixel_cost` ab, erhöhen die globale Revision monoton und schreiben zwei `place_pixel_events` – auch wenn derselbe Pixel übermalt wird. +4. Ein Placement ohne ausreichendes Guthaben liefert `insufficient_place_points` und verändert weder Wallet noch Canvas-Revision. +5. `/api/public/place` liefert nach Neustart denselben Canvas-Zustand; `/api/public/place/changes?after=` holt Live-Lücken nach. +6. `/place` synchronisiert Placements über `/api/place/ws` und fällt bei Verbindungsunterbrechungen auf den HTTP-Delta-Sync zurück. +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`. diff --git a/V4.4_NEURAL_PLACE.md b/V4.4_NEURAL_PLACE.md new file mode 100644 index 0000000..2a6bfad --- /dev/null +++ b/V4.4_NEURAL_PLACE.md @@ -0,0 +1,95 @@ +# V4.4 — Neural Place + +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 + +- Solange Neural Place aktiviert ist, erzeugt jeder **dauerhaft gespeicherte Highscore-Fortschritt** eines Spielers Place-Punkte. +- Standard: **+1,00 Score-Prozentpunkte = 100 Place-Punkte**. +- Standard: **1 Placement = 100 Place-Punkte**. +- Kleine Score-Verbesserungen werden intern in Milli-Punkten gesammelt und gehen daher nicht durch Rundung verloren. +- Auch das Übermalen eines bereits belegten Pixels ist ein Placement und kostet Punkte. +- Es gibt **keinen Zeit-Cooldown**. Die knappe Ressource ist Hunt-Fortschritt. +- Vor dem Upgrade bereits vorhandene Scores werden absichtlich **nicht rückwirkend** vergütet. Punkte entstehen ab V4.4 bei neuen Verbesserungen. + +Die Werte sind unter **Admin → RUNTIME** bzw. per Defaults konfigurierbar. Die Canvas darf erweitert werden; eine Verkleinerung wird abgewiesen, sobald dadurch bestehende Pixel außerhalb der neuen Grenzen lägen. Nach einer Größenänderung sollten offene `/place`-Seiten neu geladen werden: + +```text +DEFAULT_PLACE_ENABLED=1 +DEFAULT_PLACE_WIDTH=256 +DEFAULT_PLACE_HEIGHT=256 +DEFAULT_PLACE_POINTS_PER_SCORE=100 +DEFAULT_PLACE_PIXEL_COST=100 +``` + +## Hosted Worker / Account-Zuordnung + +Neural Place verwendet die bestehende `identity_delegations`-Zuordnung. Erzielt ein mit einem Konto verknüpfter Hosted Worker eine echte Score-Steigerung, werden die Place-Punkte atomar dem Owner-Wallet gutgeschrieben. Der Worker bleibt im Fortschritts-Audit als Quelle sichtbar. + +Damit können eigene Browser-/CLI-Fortschritte und Fortschritte mehrerer verknüpfter Worker dasselbe Place-Wallet finanzieren, ohne Worker-Identitäten zusammenzulegen. + +## Konsistenz und Missbrauchsschutz + +Die Punktevergabe läuft **in derselben SQLite-Transaktion wie die persistierte Score-Verbesserung**. Dadurch gilt: + +- kein Reward für verlorene / nicht verbessernde Tipps, +- kein Reward für Lotterie-Tipps, die nicht ausgewertet wurden, +- kein doppelter Reward bei Retry oder parallelem Request, +- Score und Place-Punkte können nicht auseinanderlaufen, +- Worker-Owner-Auflösung erfolgt zum Zeitpunkt der erfolgreichen Verbesserung. + +Die globale Leinwand verwendet eine monotone `revision`. Jedes Placement schreibt den aktuellen Pixelzustand und zusätzlich einen unveränderlichen Event-Eintrag. Das ermöglicht Live-Synchronisierung und Audit-Historie. + +## Webinterface + +`/place` ist die neue responsive Place-Oberfläche mit: + +- zoombarer und verschiebbarer globaler Canvas, +- 32-Farben-Palette, +- Pixel-Auswahl mit aktuellem Besitzer und Revision, +- Wallet für Balance, erhaltene/ausgegebene Punkte und verfügbare Pixel, +- Anzeige der verknüpften Worker, +- Liste der letzten Hunt→Place-Vergütungen, +- Live-Placement-Feed, +- eigenem Place-WebSocket plus HTTP-Fallback-Synchronisierung, +- Desktop-, Tablet- und Mobile-Layout. + +Die Canvas kann öffentlich gelesen werden. Für das Setzen eines Pixels ist die normale kryptografische Neural-Hunt-Identität/JWT-Sitzung erforderlich. + +## Persistenz + +Neue SQLite-Tabellen: + +- `place_wallets` – Saldo, Lifetime-Einnahmen/-Ausgaben, Placement-Zähler, +- `place_state` – globale Canvas-Revision, +- `place_pixels` – aktueller Zustand jedes belegten Pixels, +- `place_pixel_events` – unveränderliche Placement-Historie, +- `place_progress_events` – Audit der Hunt→Place-Punktevergabe. + +Die bestehende Admin-Profilbereinigung schützt ab V4.4 zusätzlich Identitäten mit verdientem Place-Wert oder Placements, damit ein Inaktivitäts-Cleanup keine erspielte Place-Ökonomie löscht. + +## Neue HTTP-/WebSocket-Endpunkte + +```text +GET /api/public/place +GET /api/public/place/changes?after= +GET /api/place/ws +GET /api/place/me (User-JWT) +POST /api/place/pixel (User-JWT) +``` + +Placement-Body: + +```json +{"x": 12, "y": 34, "color_index": 15} +``` + +## Betrieb + +Für große öffentliche Installationen kann die maximale Zahl der Place-WebSockets separat begrenzt werden: + +```text +WS_MAX_PLACE_CONNECTIONS=1500 +``` + +Die Place-Leinwand bleibt vollständig in SQLite persistent; ein Server-Neustart verliert weder Wallets noch Canvas noch History. diff --git a/internal/data/place.go b/internal/data/place.go new file mode 100644 index 0000000..c6caf7b --- /dev/null +++ b/internal/data/place.go @@ -0,0 +1,325 @@ +package data + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" +) + +var ( + ErrPlaceInsufficientPoints = errors.New("not enough place points") + ErrPlaceBounds = errors.New("pixel outside place canvas") + ErrPlaceColor = errors.New("invalid place color") +) + +type PlacePixel struct { + X int `json:"x"` + Y int `json:"y"` + ColorIndex int `json:"color_index"` + ClientID string `json:"client_id"` + PlacedAt time.Time `json:"placed_at"` + Revision int64 `json:"revision"` +} + +type PlacePixelEvent struct { + X int `json:"x"` + Y int `json:"y"` + ColorIndex int `json:"color_index"` + ClientID string `json:"client_id"` + CreatedAt time.Time `json:"created_at"` + Revision int64 `json:"revision"` +} + +type PlaceStats struct { + Revision int64 `json:"revision"` + FilledPixels int64 `json:"filled_pixels"` + TotalPlacements int64 `json:"total_placements"` + Participants int64 `json:"participants"` +} + +type PlaceEarning struct { + SourceClientID string `json:"source_client_id"` + TaskID string `json:"task_id"` + OldScore float64 `json:"old_score"` + NewScore float64 `json:"new_score"` + Points float64 `json:"points"` + CreatedAt time.Time `json:"created_at"` +} + +type PlaceWallet struct { + ClientID string `json:"client_id"` + BalancePoints float64 `json:"balance_points"` + EarnedPoints float64 `json:"earned_points"` + SpentPoints float64 `json:"spent_points"` + Placements int64 `json:"placements"` + OwnedPixels int64 `json:"owned_pixels"` + AvailablePixels int64 `json:"available_pixels"` + LinkedWorkers []string `json:"linked_workers"` + RecentEarnings []PlaceEarning `json:"recent_earnings"` +} + +func milliToPoints(v int64) float64 { return float64(v) / 1000.0 } + +func (s *Store) PlaceRevision(ctx context.Context) (int64, error) { + var revision int64 + err := s.DB.QueryRowContext(ctx, `SELECT revision FROM place_state WHERE id=1`).Scan(&revision) + return revision, err +} + +func (s *Store) PlacePixelsOutside(ctx context.Context, width, height int) (int64, error) { + var n int64 + err := s.DB.QueryRowContext(ctx, `SELECT count(*) FROM place_pixels WHERE x>=? OR y>=?`, width, height).Scan(&n) + return n, err +} + +func (s *Store) PlaceSnapshot(ctx context.Context) ([]PlacePixel, PlaceStats, error) { + var stats PlaceStats + if err := s.DB.QueryRowContext(ctx, `SELECT revision FROM place_state WHERE id=1`).Scan(&stats.Revision); err != nil { + return nil, stats, err + } + if err := s.DB.QueryRowContext(ctx, `SELECT count(*) FROM place_pixels`).Scan(&stats.FilledPixels); err != nil { + return nil, stats, err + } + if err := s.DB.QueryRowContext(ctx, `SELECT count(*) FROM place_pixel_events`).Scan(&stats.TotalPlacements); err != nil { + return nil, stats, err + } + if err := s.DB.QueryRowContext(ctx, `SELECT count(*) FROM place_wallets WHERE earned_milli>0 OR placements>0`).Scan(&stats.Participants); err != nil { + return nil, stats, err + } + + rows, err := s.DB.QueryContext(ctx, `SELECT x,y,color_index,client_id,placed_at,revision FROM place_pixels ORDER BY y,x`) + if err != nil { + return nil, stats, err + } + defer rows.Close() + out := make([]PlacePixel, 0, stats.FilledPixels) + for rows.Next() { + var p PlacePixel + var placedMS int64 + if err := rows.Scan(&p.X, &p.Y, &p.ColorIndex, &p.ClientID, &placedMS, &p.Revision); err != nil { + return nil, stats, err + } + p.PlacedAt = time.UnixMilli(placedMS).UTC() + out = append(out, p) + } + return out, stats, rows.Err() +} + +func (s *Store) PlaceRecent(ctx context.Context, limit int) ([]PlacePixelEvent, error) { + if limit < 1 || limit > 100 { + limit = 30 + } + rows, err := s.DB.QueryContext(ctx, `SELECT x,y,color_index,client_id,created_at,revision FROM place_pixel_events ORDER BY id DESC LIMIT ?`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]PlacePixelEvent, 0, limit) + for rows.Next() { + var e PlacePixelEvent + var createdMS int64 + if err := rows.Scan(&e.X, &e.Y, &e.ColorIndex, &e.ClientID, &createdMS, &e.Revision); err != nil { + return nil, err + } + e.CreatedAt = time.UnixMilli(createdMS).UTC() + out = append(out, e) + } + return out, rows.Err() +} + +func (s *Store) PlaceChanges(ctx context.Context, after int64, limit int) ([]PlacePixel, int64, error) { + if limit < 1 || limit > 5000 { + limit = 2000 + } + var revision int64 + if err := s.DB.QueryRowContext(ctx, `SELECT revision FROM place_state WHERE id=1`).Scan(&revision); err != nil { + return nil, 0, err + } + rows, err := s.DB.QueryContext(ctx, `SELECT x,y,color_index,client_id,placed_at,revision FROM place_pixels WHERE revision>? ORDER BY revision LIMIT ?`, after, limit) + if err != nil { + return nil, revision, err + } + defer rows.Close() + out := make([]PlacePixel, 0) + for rows.Next() { + var p PlacePixel + var placedMS int64 + if err := rows.Scan(&p.X, &p.Y, &p.ColorIndex, &p.ClientID, &placedMS, &p.Revision); err != nil { + return nil, revision, err + } + p.PlacedAt = time.UnixMilli(placedMS).UTC() + out = append(out, p) + } + if err := rows.Err(); err != nil { + return nil, revision, err + } + if len(out) == limit && len(out) > 0 { + revision = out[len(out)-1].Revision + } + return out, revision, nil +} + +func (s *Store) PlaceWallet(ctx context.Context, clientID string, pixelCostPoints int) (PlaceWallet, error) { + now := time.Now().UTC().UnixMilli() + if _, err := s.DB.ExecContext(ctx, `INSERT INTO place_wallets(client_id,updated_at) VALUES(?,?) ON CONFLICT(client_id) DO NOTHING`, clientID, now); err != nil { + return PlaceWallet{}, err + } + var balance, earned, spent int64 + var out PlaceWallet + out.ClientID = clientID + out.LinkedWorkers = []string{} + out.RecentEarnings = []PlaceEarning{} + if err := s.DB.QueryRowContext(ctx, `SELECT balance_milli,earned_milli,spent_milli,placements FROM place_wallets WHERE client_id=?`, clientID). + Scan(&balance, &earned, &spent, &out.Placements); err != nil { + return out, err + } + out.BalancePoints = milliToPoints(balance) + out.EarnedPoints = milliToPoints(earned) + out.SpentPoints = milliToPoints(spent) + if err := s.DB.QueryRowContext(ctx, `SELECT count(*) FROM place_pixels WHERE client_id=?`, clientID).Scan(&out.OwnedPixels); err != nil { + return out, err + } + costMilli := int64(pixelCostPoints) * 1000 + if costMilli > 0 { + out.AvailablePixels = balance / costMilli + } + + workers, err := s.DB.QueryContext(ctx, `SELECT worker_client_id FROM identity_delegations WHERE owner_client_id=? ORDER BY worker_client_id`, clientID) + if err != nil { + return out, err + } + for workers.Next() { + var id string + if err := workers.Scan(&id); err != nil { + workers.Close() + return out, err + } + out.LinkedWorkers = append(out.LinkedWorkers, id) + } + if err := workers.Err(); err != nil { + workers.Close() + return out, err + } + 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) + 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 { + rows.Close() + return out, err + } + e.Points = milliToPoints(pointsMilli) + e.CreatedAt = time.UnixMilli(createdMS).UTC() + out.RecentEarnings = append(out.RecentEarnings, e) + } + if err := rows.Err(); err != nil { + rows.Close() + return out, err + } + rows.Close() + return out, nil +} + +func (s *Store) PlacePixelAt(ctx context.Context, x, y int) (PlacePixel, error) { + var p PlacePixel + var placedMS int64 + err := s.DB.QueryRowContext(ctx, `SELECT x,y,color_index,client_id,placed_at,revision FROM place_pixels WHERE x=? AND y=?`, x, y). + Scan(&p.X, &p.Y, &p.ColorIndex, &p.ClientID, &placedMS, &p.Revision) + if err != nil { + return p, err + } + p.PlacedAt = time.UnixMilli(placedMS).UTC() + return p, nil +} + +// PlacePixel spends points and updates the shared canvas in one transaction. +// There is intentionally no time cooldown: Hunt progress is the scarce resource. +func (s *Store) PlacePixel(ctx context.Context, clientID string, x, y, colorIndex, width, height, pixelCostPoints, paletteSize int) (PlacePixel, PlaceWallet, error) { + if x < 0 || y < 0 || x >= width || y >= height { + return PlacePixel{}, PlaceWallet{}, ErrPlaceBounds + } + if colorIndex < 0 || colorIndex >= paletteSize { + return PlacePixel{}, PlaceWallet{}, ErrPlaceColor + } + costMilli := int64(pixelCostPoints) * 1000 + if costMilli <= 0 { + return PlacePixel{}, PlaceWallet{}, fmt.Errorf("invalid place pixel cost") + } + + tx, err := s.DB.BeginTx(ctx, nil) + if err != nil { + return PlacePixel{}, PlaceWallet{}, err + } + defer tx.Rollback() + nowMS := time.Now().UTC().UnixMilli() + if _, err := tx.ExecContext(ctx, `INSERT INTO place_wallets(client_id,updated_at) VALUES(?,?) ON CONFLICT(client_id) DO NOTHING`, clientID, nowMS); err != nil { + return PlacePixel{}, PlaceWallet{}, err + } + res, err := tx.ExecContext(ctx, `UPDATE place_wallets SET balance_milli=balance_milli-?,spent_milli=spent_milli+?,placements=placements+1,updated_at=? WHERE client_id=? AND balance_milli>=?`, costMilli, costMilli, nowMS, clientID, costMilli) + if err != nil { + return PlacePixel{}, PlaceWallet{}, err + } + n, _ := res.RowsAffected() + if n != 1 { + return PlacePixel{}, PlaceWallet{}, ErrPlaceInsufficientPoints + } + if _, err := tx.ExecContext(ctx, `UPDATE place_state SET revision=revision+1,updated_at=? WHERE id=1`, nowMS); err != nil { + return PlacePixel{}, PlaceWallet{}, err + } + var revision int64 + if err := tx.QueryRowContext(ctx, `SELECT revision FROM place_state WHERE id=1`).Scan(&revision); err != nil { + return PlacePixel{}, PlaceWallet{}, err + } + if _, err := tx.ExecContext(ctx, `INSERT INTO place_pixels(x,y,color_index,client_id,placed_at,revision) VALUES(?,?,?,?,?,?) ON CONFLICT(x,y) DO UPDATE SET color_index=excluded.color_index,client_id=excluded.client_id,placed_at=excluded.placed_at,revision=excluded.revision`, x, y, colorIndex, clientID, nowMS, revision); err != nil { + return PlacePixel{}, PlaceWallet{}, err + } + if _, err := tx.ExecContext(ctx, `INSERT INTO place_pixel_events(x,y,color_index,client_id,cost_milli,created_at,revision) VALUES(?,?,?,?,?,?,?)`, x, y, colorIndex, clientID, costMilli, nowMS, revision); err != nil { + return PlacePixel{}, PlaceWallet{}, err + } + if err := tx.Commit(); err != nil { + return PlacePixel{}, PlaceWallet{}, err + } + + p := PlacePixel{X: x, Y: y, ColorIndex: colorIndex, ClientID: clientID, PlacedAt: time.UnixMilli(nowMS).UTC(), Revision: revision} + wallet, err := s.PlaceWallet(ctx, clientID, pixelCostPoints) + return p, wallet, err +} + +// placeRewardImprovementTx credits the reward identity for genuine durable +// score progress. Delegated workers resolve to their owner at the instant the +// progress is committed. A minimum of one milli-point ensures every positive +// score increase is represented, even if it is extremely small. +func (s *Store) placeRewardImprovementTx(ctx context.Context, tx *sql.Tx, taskID, sourceClientID string, oldScore, newScore float64, pointsPerScore int, nowMS int64) (string, int64, error) { + if pointsPerScore <= 0 || newScore <= oldScore { + return "", 0, nil + } + 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): + // Direct player progress credits the player identity itself. + case err != nil: + return "", 0, err + } + pointsMilli := int64((newScore-oldScore)*float64(pointsPerScore)*1000.0 + 0.5) + if pointsMilli < 1 { + pointsMilli = 1 + } + 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 "", 0, err + } + if _, err := tx.ExecContext(ctx, `INSERT INTO place_progress_events(owner_client_id,source_client_id,task_id,old_score,new_score,points_milli,created_at) VALUES(?,?,?,?,?,?,?)`, owner, sourceClientID, taskID, oldScore, newScore, pointsMilli, nowMS); err != nil { + return "", 0, err + } + return owner, pointsMilli, nil +} diff --git a/internal/data/place_test.go b/internal/data/place_test.go new file mode 100644 index 0000000..3731d87 --- /dev/null +++ b/internal/data/place_test.go @@ -0,0 +1,107 @@ +package data + +import ( + "context" + "errors" + "testing" + "time" + + "neuralhunt/internal/auth" +) + +func TestPlaceWorkerProgressCreditsOwnerAndFundsPlacements(t *testing.T) { + ctx := context.Background() + db, err := OpenSQLite(ctx, t.TempDir()+"/place.db") + if err != nil { + t.Fatal(err) + } + defer db.Close() + s := New(db) + jwk := auth.PublicJWK{Kty: "EC", Crv: "P-256", X: "AQ", Y: "Ag"} + if err := s.UpsertClient(ctx, "owner", jwk); err != nil { + t.Fatal(err) + } + if err := s.UpsertClient(ctx, "worker", 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) + } + + tx, err := db.BeginTx(ctx, nil) + if err != nil { + t.Fatal(err) + } + owner, milli, err := s.placeRewardImprovementTx(ctx, tx, tasks[0].ID, "worker", 10, 12.5, 100, time.Now().UTC().UnixMilli()) + if err != nil { + t.Fatal(err) + } + if owner != "owner" || milli != 250000 { + t.Fatalf("owner=%q milli=%d", owner, milli) + } + if err := tx.Commit(); err != nil { + t.Fatal(err) + } + + wallet, err := s.PlaceWallet(ctx, "owner", 100) + if err != nil { + t.Fatal(err) + } + if wallet.BalancePoints != 250 || wallet.AvailablePixels != 2 || len(wallet.LinkedWorkers) != 1 || wallet.LinkedWorkers[0] != "worker" { + t.Fatalf("unexpected wallet: %+v", wallet) + } + + first, wallet, err := s.PlacePixel(ctx, "owner", 7, 9, 3, 256, 256, 100, 32) + if err != nil { + t.Fatal(err) + } + if first.Revision != 1 || wallet.BalancePoints != 150 || wallet.Placements != 1 || wallet.OwnedPixels != 1 { + t.Fatalf("first=%+v wallet=%+v", first, wallet) + } + second, wallet, err := s.PlacePixel(ctx, "owner", 7, 9, 8, 256, 256, 100, 32) + if err != nil { + t.Fatal(err) + } + if second.Revision != 2 || wallet.BalancePoints != 50 || wallet.Placements != 2 || wallet.OwnedPixels != 1 { + t.Fatalf("second=%+v wallet=%+v", second, wallet) + } + if _, _, err := s.PlacePixel(ctx, "owner", 8, 9, 1, 256, 256, 100, 32); !errors.Is(err, ErrPlaceInsufficientPoints) { + t.Fatalf("expected insufficient points, got %v", err) + } + pixels, stats, err := s.PlaceSnapshot(ctx) + if err != nil { + t.Fatal(err) + } + if len(pixels) != 1 || pixels[0].ColorIndex != 8 || stats.Revision != 2 || stats.TotalPlacements != 2 { + t.Fatalf("pixels=%+v stats=%+v", pixels, stats) + } + changes, rev, err := s.PlaceChanges(ctx, 0, 100) + if err != nil { + t.Fatal(err) + } + if len(changes) != 1 || changes[0].Revision != 2 || changes[0].ColorIndex != 8 || rev != 2 { + t.Fatalf("changes=%+v rev=%d", changes, rev) + } + recent, err := s.PlaceRecent(ctx, 2) + if err != nil { + t.Fatal(err) + } + if len(recent) != 2 || recent[0].Revision != 2 || recent[1].Revision != 1 { + t.Fatalf("recent=%+v", recent) + } + outside, err := s.PlacePixelsOutside(ctx, 7, 256) + if err != nil || outside != 1 { + t.Fatalf("outside=%d err=%v", outside, err) + } + outside, err = s.PlacePixelsOutside(ctx, 8, 10) + if err != nil || outside != 0 { + t.Fatalf("inside bounds outside=%d err=%v", outside, err) + } +} diff --git a/internal/data/schema.sql b/internal/data/schema.sql index 66113c3..5b47063 100644 --- a/internal/data/schema.sql +++ b/internal/data/schema.sql @@ -208,3 +208,60 @@ CREATE TABLE IF NOT EXISTS hosted_credit_events ( last_error TEXT NOT NULL DEFAULT '' ); CREATE INDEX IF NOT EXISTS hosted_credit_events_pending_idx ON hosted_credit_events(delivered_at,created_at); + +-- Neural Place: a shared, r/place-inspired canvas funded exclusively by +-- genuine Hunt score improvements. Point balances use milli-points so tiny +-- score increases accumulate instead of being rounded away. +CREATE TABLE IF NOT EXISTS place_wallets ( + client_id TEXT PRIMARY KEY REFERENCES clients(id) ON DELETE CASCADE, + balance_milli INTEGER NOT NULL DEFAULT 0, + earned_milli INTEGER NOT NULL DEFAULT 0, + spent_milli INTEGER NOT NULL DEFAULT 0, + placements INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS place_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + revision INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0 +); +INSERT OR IGNORE INTO place_state(id,revision,updated_at) VALUES(1,0,0); + +CREATE TABLE IF NOT EXISTS place_pixels ( + x INTEGER NOT NULL, + y INTEGER NOT NULL, + color_index INTEGER NOT NULL, + client_id TEXT NOT NULL, + placed_at INTEGER NOT NULL, + revision INTEGER NOT NULL, + PRIMARY KEY (x,y) +); +CREATE INDEX IF NOT EXISTS place_pixels_revision_idx ON place_pixels(revision); +CREATE INDEX IF NOT EXISTS place_pixels_client_idx ON place_pixels(client_id); + +CREATE TABLE IF NOT EXISTS place_pixel_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + x INTEGER NOT NULL, + y INTEGER NOT NULL, + color_index INTEGER NOT NULL, + client_id TEXT NOT NULL, + cost_milli INTEGER NOT NULL, + created_at INTEGER NOT NULL, + revision INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS place_pixel_events_created_idx ON place_pixel_events(created_at DESC); +CREATE INDEX IF NOT EXISTS place_pixel_events_client_idx ON place_pixel_events(client_id,created_at DESC); + +CREATE TABLE IF NOT EXISTS place_progress_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_client_id TEXT NOT NULL, + source_client_id TEXT NOT NULL, + task_id TEXT NOT NULL, + old_score REAL NOT NULL, + new_score REAL NOT NULL, + points_milli INTEGER NOT NULL, + created_at INTEGER NOT NULL +); +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); diff --git a/internal/data/store.go b/internal/data/store.go index 04f0525..cd5a7b6 100644 --- a/internal/data/store.go +++ b/internal/data/store.go @@ -1689,13 +1689,15 @@ type ProfileCleanupCandidate struct { } // InactiveNonWinnerClients returns identities whose persisted last activity is -// older than cutoffMS and that have never been recorded as a task winner. +// older than cutoffMS and that have no durable winner, delegation, or Neural +// Place economy state that must survive generic profile cleanup. func (s *Store) InactiveNonWinnerClients(ctx context.Context, cutoffMS int64) ([]ProfileCleanupCandidate, error) { rows, err := s.DB.QueryContext(ctx, `SELECT c.id,c.last_seen FROM clients c WHERE c.last_seen < ? AND NOT EXISTS (SELECT 1 FROM tasks t WHERE t.winner_client_id=c.id OR t.winner_worker_client_id=c.id OR t.artifact_owner_client_id=c.id) AND NOT EXISTS (SELECT 1 FROM identity_delegations d WHERE d.worker_client_id=c.id OR d.owner_client_id=c.id) + AND NOT EXISTS (SELECT 1 FROM place_wallets pw WHERE pw.client_id=c.id AND (pw.earned_milli>0 OR pw.spent_milli>0 OR pw.placements>0 OR pw.balance_milli>0)) ORDER BY c.last_seen ASC`, cutoffMS) if err != nil { return nil, err @@ -1722,8 +1724,19 @@ func (s *Store) OldWinnerCount(ctx context.Context, cutoffMS int64) (int64, erro return n, err } +// OldPlaceParticipantCount reports old identities whose earned Place economy +// state protects them from generic profile cleanup. Deleting such an identity +// would otherwise cascade its wallet and destroy durable earned value. +func (s *Store) OldPlaceParticipantCount(ctx context.Context, cutoffMS int64) (int64, error) { + var n int64 + err := s.DB.QueryRowContext(ctx, `SELECT count(*) FROM clients c + WHERE c.last_seen < ? + AND EXISTS (SELECT 1 FROM place_wallets pw WHERE pw.client_id=c.id AND (pw.earned_milli>0 OR pw.spent_milli>0 OR pw.placements>0 OR pw.balance_milli>0))`, cutoffMS).Scan(&n) + return n, err +} + // DeleteInactiveNonWinnerClients deletes only the explicitly supplied client -// IDs and re-checks both age and winner protection inside the transaction. The +// IDs and re-checks age plus winner/delegation/Place protection in the transaction. The // re-check makes a concurrent login safe because login/WS connect refreshes // clients.last_seen before this DELETE can match it. func (s *Store) DeleteInactiveNonWinnerClients(ctx context.Context, cutoffMS int64, ids []string) ([]string, error) { @@ -1746,7 +1759,8 @@ func (s *Store) DeleteInactiveNonWinnerClients(ctx context.Context, cutoffMS int res, err := tx.ExecContext(ctx, `DELETE FROM clients WHERE id=? AND last_seen < ? AND NOT EXISTS (SELECT 1 FROM tasks t WHERE t.winner_client_id=clients.id OR t.winner_worker_client_id=clients.id OR t.artifact_owner_client_id=clients.id) - AND NOT EXISTS (SELECT 1 FROM identity_delegations d WHERE d.worker_client_id=clients.id OR d.owner_client_id=clients.id)`, id, cutoffMS) + AND NOT EXISTS (SELECT 1 FROM identity_delegations d WHERE d.worker_client_id=clients.id OR d.owner_client_id=clients.id) + AND NOT EXISTS (SELECT 1 FROM place_wallets pw WHERE pw.client_id=clients.id AND (pw.earned_milli>0 OR pw.spent_milli>0 OR pw.placements>0 OR pw.balance_milli>0))`, id, cutoffMS) if err != nil { return nil, err } @@ -1771,6 +1785,14 @@ func (s *Store) TouchClient(ctx context.Context, id string) { _, _ = s.DB.ExecContext(ctx, `UPDATE clients SET last_seen=? WHERE id=?`, time.Now().UTC().UnixMilli(), id) } +// TouchClientThrottled marks passive authenticated activity without turning a +// frequently-polled read endpoint into a SQLite write on every request. +func (s *Store) TouchClientThrottled(ctx context.Context, id string, minInterval time.Duration) { + now := time.Now().UTC().UnixMilli() + cutoff := now - minInterval.Milliseconds() + _, _ = s.DB.ExecContext(ctx, `UPDATE clients SET last_seen=? WHERE id=? AND last_seen oldScore { + if _, _, err := s.placeRewardImprovementTx(ctx, tx, t.ID, cid, oldScore, score, placePointsPerScore, lastMS); err != nil { + return Point{}, err + } + } if correct { if rewardOwner == "" { rewardOwner = cid diff --git a/internal/server/place.go b/internal/server/place.go new file mode 100644 index 0000000..0e96a17 --- /dev/null +++ b/internal/server/place.go @@ -0,0 +1,159 @@ +package server + +import ( + "errors" + "net/http" + "strconv" + "time" + + "neuralhunt/internal/data" + wsx "neuralhunt/internal/ws" +) + +var placePalette = []string{ + "#FFFFFF", "#D4D7D9", "#898D90", "#515252", "#000000", "#FFB470", "#FF8A3D", "#E64B35", + "#FF5C7A", "#E33765", "#B72B73", "#811E9F", "#B44AC0", "#7E57C2", "#5A4FCF", "#3690EA", + "#2450A4", "#51E9F4", "#00A8B8", "#00CCC0", "#34D399", "#00A368", "#00756F", "#7EED56", + "#46A758", "#A8D26D", "#FFD635", "#F9A31B", "#D6A25E", "#9C6926", "#6D482F", "#493B2A", +} + +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, + } +} + +func (s *Server) publicPlace(w http.ResponseWriter, r *http.Request) { + pixels, stats, err := s.store.PlaceSnapshot(r.Context()) + if err != nil { + jsonOut(w, 500, map[string]string{"error": "place snapshot failed"}) + return + } + recent, _ := s.store.PlaceRecent(r.Context(), 30) + w.Header().Set("Cache-Control", "no-store") + jsonOut(w, 200, map[string]any{"config": s.placeConfig(), "stats": stats, "pixels": pixels, "recent": recent}) +} + +func (s *Server) publicPlaceChanges(w http.ResponseWriter, r *http.Request) { + after, _ := strconv.ParseInt(r.URL.Query().Get("after"), 10, 64) + pixels, revision, err := s.store.PlaceChanges(r.Context(), after, 5000) + if err != nil { + jsonOut(w, 500, map[string]string{"error": "place changes failed"}) + return + } + w.Header().Set("Cache-Control", "no-store") + jsonOut(w, 200, map[string]any{"revision": revision, "pixels": pixels}) +} + +func (s *Server) placeMe(w http.ResponseWriter, r *http.Request) { + c := claims(r) + if !s.store.ClientExists(r.Context(), c.ClientID) { + jsonOut(w, http.StatusUnauthorized, map[string]string{"error": "identity not registered in this database"}) + return + } + s.store.TouchClientThrottled(r.Context(), c.ClientID, 5*time.Minute) + cfg := s.settings.Get() + wallet, err := s.store.PlaceWallet(r.Context(), c.ClientID, cfg.PlacePixelCost) + if err != nil { + jsonOut(w, 500, map[string]string{"error": "place wallet failed"}) + return + } + jsonOut(w, 200, map[string]any{"config": s.placeConfig(), "wallet": wallet}) +} + +func (s *Server) placePixel(w http.ResponseWriter, r *http.Request) { + cfg := s.settings.Get() + if cfg.PlaceEnabled != 1 { + jsonAPIError(w, http.StatusLocked, "place_disabled", "Neural Place is currently disabled", nil) + return + } + var in struct { + X int `json:"x"` + Y int `json:"y"` + ColorIndex int `json:"color_index"` + } + if err := decode(r, &in); err != nil { + jsonOut(w, 400, map[string]string{"error": "bad json: " + err.Error()}) + return + } + c := claims(r) + if !s.store.ClientExists(r.Context(), c.ClientID) { + jsonOut(w, http.StatusUnauthorized, map[string]string{"error": "identity not registered in this database"}) + return + } + s.store.TouchClient(r.Context(), c.ClientID) + p, wallet, err := s.store.PlacePixel(r.Context(), c.ClientID, in.X, in.Y, in.ColorIndex, cfg.PlaceWidth, cfg.PlaceHeight, cfg.PlacePixelCost, len(placePalette)) + if err != nil { + // The pixel transaction may already be committed even if the optional + // wallet refresh immediately afterwards fails. Never report that case as + // a failed placement: a client retry would spend the pixel cost twice. + if p.Revision > 0 { + _ = s.placeHub.Publish(r.Context(), wsx.Event{Type: "place_pixel", Data: p}) + jsonOut(w, 200, map[string]any{"pixel": p, "wallet": nil, "wallet_refresh_required": true}) + return + } + switch { + case errors.Is(err, data.ErrPlaceInsufficientPoints): + jsonAPIError(w, http.StatusPaymentRequired, "insufficient_place_points", "not enough Place points for this pixel", map[string]any{"pixel_cost": cfg.PlacePixelCost}) + case errors.Is(err, data.ErrPlaceBounds), errors.Is(err, data.ErrPlaceColor): + jsonOut(w, 400, map[string]string{"error": err.Error()}) + default: + jsonOut(w, 500, map[string]string{"error": "pixel placement failed"}) + } + return + } + _ = s.placeHub.Publish(r.Context(), wsx.Event{Type: "place_pixel", Data: p}) + jsonOut(w, 200, map[string]any{"pixel": p, "wallet": wallet}) +} + +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) + return + } + defer s.placeWSCount.Add(-1) + conn, err := s.upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + cl := wsx.NewClient(conn, "", "place", true) + s.placeHub.Add(cl) + defer s.placeHub.Remove(cl) + revision, _ := s.store.PlaceRevision(r.Context()) + cl.Enqueue(wsx.Event{Type: "place_ready", Data: map[string]any{"revision": revision}}) + + conn.SetReadLimit(1024) + _ = conn.SetReadDeadline(time.Now().Add(90 * time.Second)) + conn.SetPongHandler(func(string) error { return conn.SetReadDeadline(time.Now().Add(90 * time.Second)) }) + ping := time.NewTicker(30 * time.Second) + defer ping.Stop() + done := make(chan struct{}) + go func() { + defer close(done) + for { + if _, _, err := conn.ReadMessage(); err != nil { + return + } + } + }() + for { + select { + case <-done: + return + case <-r.Context().Done(): + return + case <-ping.C: + if err := cl.Ping(); err != nil { + return + } + } + } +} diff --git a/internal/server/profile_cleanup_test.go b/internal/server/profile_cleanup_test.go index a59d293..d1bc73b 100644 --- a/internal/server/profile_cleanup_test.go +++ b/internal/server/profile_cleanup_test.go @@ -53,3 +53,42 @@ func TestProfileCleanupDurationGuard(t *testing.T) { t.Fatalf("got=%d err=%v", got, err) } } + +func TestProfileCleanupProtectsPlaceParticipant(t *testing.T) { + ctx := context.Background() + db, err := data.OpenSQLite(ctx, t.TempDir()+"/cleanup-place.db") + if err != nil { + t.Fatal(err) + } + defer db.Close() + store := data.New(db) + jwk := auth.PublicJWK{Kty: "EC", Crv: "P-256", X: "AQ", Y: "Ag"} + if err := store.UpsertClient(ctx, "place_old", jwk); err != nil { + t.Fatal(err) + } + old := time.Now().UTC().Add(-10 * 24 * time.Hour).UnixMilli() + if _, err := db.ExecContext(ctx, `UPDATE clients SET last_seen=? WHERE id='place_old'`, old); err != nil { + t.Fatal(err) + } + if _, err := db.ExecContext(ctx, `INSERT INTO place_wallets(client_id,balance_milli,earned_milli,updated_at) VALUES('place_old',1000,1000,?)`, old); err != nil { + t.Fatal(err) + } + s := &Server{store: store, runtime: rtx.New()} + preview, ids, err := s.profileCleanupPreview(ctx, 7*24*60*60) + if err != nil { + t.Fatal(err) + } + if preview.Eligible != 0 || preview.ProtectedPlace != 1 { + t.Fatalf("unexpected preview: %+v", preview) + } + if len(ids) != 0 { + t.Fatalf("place participant should be protected, got ids %#v", ids) + } + deleted, err := store.DeleteInactiveNonWinnerClients(ctx, time.Now().UTC().Add(-7*24*time.Hour).UnixMilli(), []string{"place_old"}) + if err != nil { + t.Fatal(err) + } + if len(deleted) != 0 || !store.ClientExists(ctx, "place_old") { + t.Fatal("place participant was deleted") + } +} diff --git a/internal/server/server.go b/internal/server/server.go index a064209..6098578 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -38,22 +38,23 @@ import ( ) type Server struct { - store *data.Store - auth *auth.Manager - settings *settings.Manager - hub *wsx.Hub - runtime *rtx.State - artifactWorker *artifact.Worker - lottery *guessLottery - adminUser, adminPass, staticDir, artifactDir string - internalServiceSecret string - customerServiceInternalURL string - customerServiceHTTP *http.Client - upgrader websocket.Upgrader - wsAllowedOrigins map[string]struct{} - maxUserWS, maxLeaderboardWS int64 - userWSCount, leaderboardWSCount, adminWSCount atomic.Int64 - adminSessions sync.Map // sid -> exp unix seconds + store *data.Store + auth *auth.Manager + settings *settings.Manager + hub *wsx.Hub + placeHub *wsx.Hub + runtime *rtx.State + artifactWorker *artifact.Worker + lottery *guessLottery + adminUser, adminPass, staticDir, artifactDir string + internalServiceSecret string + customerServiceInternalURL string + customerServiceHTTP *http.Client + upgrader websocket.Upgrader + wsAllowedOrigins map[string]struct{} + maxUserWS, maxLeaderboardWS, maxPlaceWS int64 + userWSCount, leaderboardWSCount, placeWSCount, adminWSCount atomic.Int64 + adminSessions sync.Map // sid -> exp unix seconds } func New(store *data.Store, a *auth.Manager, sm *settings.Manager, hub *wsx.Hub, runtimeState *rtx.State, artifactDir string, artifactWorker *artifact.Worker) *Server { @@ -62,6 +63,7 @@ func New(store *data.Store, a *auth.Manager, sm *settings.Manager, hub *wsx.Hub, auth: a, settings: sm, hub: hub, + placeHub: wsx.New(), runtime: runtimeState, artifactWorker: artifactWorker, lottery: newGuessLottery(func(d beaconDrawAudit) { @@ -77,6 +79,7 @@ func New(store *data.Store, a *auth.Manager, sm *settings.Manager, hub *wsx.Hub, wsAllowedOrigins: parseOriginAllowlist(os.Getenv("WS_ALLOWED_ORIGINS")), maxUserWS: int64(envIntServer("WS_MAX_USER_CONNECTIONS", 5000)), maxLeaderboardWS: int64(envIntServer("WS_MAX_LEADERBOARD_CONNECTIONS", 500)), + maxPlaceWS: int64(envIntServer("WS_MAX_PLACE_CONNECTIONS", 1500)), } s.upgrader = websocket.Upgrader{CheckOrigin: s.checkWSOrigin, Subprotocols: []string{"neuralhunt.v1"}} return s @@ -293,7 +296,7 @@ func (s *Server) Routes() http.Handler { w.Header().Set("Strict-Transport-Security", "max-age=31536000") w.Header().Set("Cross-Origin-Opener-Policy", "same-origin") path := strings.ToLower(req.URL.Path) - if path == "/" || path == "/admin" || path == "/leaderboard" || strings.HasPrefix(path, "/api/admin") || strings.HasPrefix(path, "/api/auth") || strings.HasSuffix(path, ".html") || strings.HasSuffix(path, ".js") || strings.HasSuffix(path, ".css") { + if path == "/" || path == "/admin" || path == "/leaderboard" || path == "/place" || strings.HasPrefix(path, "/api/admin") || strings.HasPrefix(path, "/api/auth") || strings.HasSuffix(path, ".html") || strings.HasSuffix(path, ".js") || strings.HasSuffix(path, ".css") { w.Header().Set("Cache-Control", "no-store") } next.ServeHTTP(w, req) @@ -304,9 +307,12 @@ func (s *Server) Routes() http.Handler { r.Get("/api/public/artifacts", s.publicArtifacts) r.Get("/api/public/beacon/{id}/latest", s.latestBeaconDraw) r.Get("/api/public/tasks", s.publicTaskCatalog) + r.Get("/api/public/place", s.publicPlace) + r.Get("/api/public/place/changes", s.publicPlaceChanges) r.Get("/api/public/artifacts/{id}/preview", s.publicArtifactPreview) r.Get("/api/public/tasks/{id}/style-reference", s.publicTaskStyleReference) r.Get("/api/leaderboard/ws", s.leaderboardWS) + r.Get("/api/place/ws", s.placeWS) r.Post("/api/auth/challenge", s.challenge) r.Post("/api/auth/login", s.login) r.Post("/api/admin/login", s.adminLogin) @@ -323,6 +329,8 @@ func (s *Server) Routes() http.Handler { r.Post("/api/tasks/{id}/guess", s.guess) r.Get("/api/tasks/{id}/points", s.points) r.Get("/api/me", s.me) + r.Get("/api/place/me", s.placeMe) + r.Post("/api/place/pixel", s.placePixel) r.Get("/api/me/artifacts", s.myArtifacts) r.Get("/api/me/artifacts/{id}/download", s.myArtifactDownload) r.Post("/api/me/customer-link", s.customerLinkCode) @@ -847,7 +855,11 @@ func (s *Server) guess(w http.ResponseWriter, r *http.Request) { if correct && draw.BeaconEnabled { beaconPath, beaconBoost, beaconRound = draw.ChosenPath, draw.BoostedPath, draw.BeaconRound } - p, err := s.store.PersistImprovement(r.Context(), t, c.ClientID, rewardOwner, accepted.State.NextSeq, accepted.State.GuessCount, accepted.State.LastGuess, accepted.State.BestScore, in.Guess, in.Signature, correct, beaconPath, beaconBoost, beaconRound) + placePointsPerScore := 0 + if placeCfg := s.settings.Get(); placeCfg.PlaceEnabled == 1 { + placePointsPerScore = placeCfg.PlacePointsPerScore + } + p, err := s.store.PersistImprovement(r.Context(), t, c.ClientID, rewardOwner, accepted.State.NextSeq, accepted.State.GuessCount, accepted.State.LastGuess, accepted.State.BestScore, in.Guess, in.Signature, correct, beaconPath, beaconBoost, beaconRound, placePointsPerScore) if err != nil { s.runtime.Restore(t.Task, c.ClientID, accepted.State.NextSeq, accepted.Previous) switch { @@ -1346,6 +1358,7 @@ type profileCleanupPreview struct { InactiveForSeconds int64 `json:"inactive_for_seconds"` Eligible int `json:"eligible"` ProtectedWinners int64 `json:"protected_winners"` + ProtectedPlace int64 `json:"protected_place"` ProtectedConnected int `json:"protected_connected"` OldestEligibleMS int64 `json:"oldest_eligible_ms,omitempty"` NewestEligibleMS int64 `json:"newest_eligible_ms,omitempty"` @@ -1374,8 +1387,12 @@ func (s *Server) profileCleanupPreview(ctx context.Context, inactiveForSeconds i if err != nil { return profileCleanupPreview{}, nil, err } + protectedPlace, err := s.store.OldPlaceParticipantCount(ctx, cutoff) + if err != nil { + return profileCleanupPreview{}, nil, err + } ids := make([]string, 0, len(candidates)) - out := profileCleanupPreview{CutoffMS: cutoff, InactiveForSeconds: inactiveForSeconds, ProtectedWinners: protectedWinners} + out := profileCleanupPreview{CutoffMS: cutoff, InactiveForSeconds: inactiveForSeconds, ProtectedWinners: protectedWinners, ProtectedPlace: protectedPlace} for _, c := range candidates { if s.runtime.IsConnected(c.ClientID) { out.ProtectedConnected++ @@ -1437,6 +1454,7 @@ func (s *Server) adminProfileCleanup(w http.ResponseWriter, r *http.Request) { "deleted": len(deleted), "eligible_before": preview.Eligible, "protected_winners": preview.ProtectedWinners, + "protected_place": preview.ProtectedPlace, "protected_connected": preview.ProtectedConnected, "cutoff_ms": preview.CutoffMS, "inactive_for_seconds": seconds, @@ -1457,11 +1475,16 @@ func (s *Server) adminOverview(w http.ResponseWriter, r *http.Request) { func (s *Server) adminPerformance(w http.ResponseWriter, r *http.Request) { rm := s.runtime.Metrics() wm := s.hub.Metrics() + pm := wsx.Metrics{} + if s.placeHub != nil { + pm = s.placeHub.Metrics() + } var ms gort.MemStats gort.ReadMemStats(&ms) jsonOut(w, 200, map[string]any{ - "runtime": rm, - "websocket": wm, + "runtime": rm, + "websocket": wm, + "place_websocket": pm, "process": map[string]any{ "goroutines": gort.NumGoroutine(), "heap_bytes": ms.HeapAlloc, @@ -1480,6 +1503,22 @@ func (s *Server) adminSettingsPut(w http.ResponseWriter, r *http.Request) { jsonOut(w, 400, map[string]string{"error": "bad json"}) return } + if err := settings.Validate(v); err != nil { + jsonOut(w, 400, map[string]string{"error": err.Error()}) + return + } + current := s.settings.Get() + if v.PlaceWidth < current.PlaceWidth || v.PlaceHeight < current.PlaceHeight { + outside, err := s.store.PlacePixelsOutside(r.Context(), v.PlaceWidth, v.PlaceHeight) + if err != nil { + jsonOut(w, 500, map[string]string{"error": "place canvas bounds check failed"}) + return + } + if outside > 0 { + jsonOut(w, 400, map[string]string{"error": fmt.Sprintf("place canvas cannot shrink: %d existing pixels would fall outside the new dimensions", outside)}) + return + } + } if err := s.settings.Update(r.Context(), v); err != nil { jsonOut(w, 400, map[string]string{"error": err.Error()}) return diff --git a/internal/settings/settings.go b/internal/settings/settings.go index f6eef3e..8e746b5 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -31,6 +31,11 @@ type Runtime struct { PresenceTTLSec int `json:"presence_ttl_sec"` DefaultMaxNodes int `json:"default_max_nodes"` PublicScorePrecision int `json:"public_score_precision"` + PlaceEnabled int `json:"place_enabled"` + PlaceWidth int `json:"place_width"` + PlaceHeight int `json:"place_height"` + PlacePointsPerScore int `json:"place_points_per_score"` + PlacePixelCost int `json:"place_pixel_cost"` ArtifactPreset string `json:"artifact_preset"` ArtifactProvider string `json:"artifact_provider"` ArtifactModel string `json:"artifact_model"` @@ -97,6 +102,11 @@ func Defaults() Runtime { PresenceTTLSec: envInt("DEFAULT_PRESENCE_TTL_SEC", 35), DefaultMaxNodes: envInt("DEFAULT_MAX_NODES", 2000), PublicScorePrecision: envInt("DEFAULT_PUBLIC_SCORE_PRECISION", 2), + PlaceEnabled: envInt("DEFAULT_PLACE_ENABLED", 1), + PlaceWidth: envInt("DEFAULT_PLACE_WIDTH", 256), + PlaceHeight: envInt("DEFAULT_PLACE_HEIGHT", 256), + PlacePointsPerScore: envInt("DEFAULT_PLACE_POINTS_PER_SCORE", 100), + PlacePixelCost: envInt("DEFAULT_PLACE_PIXEL_COST", 100), ArtifactPreset: strings.ToLower(envString("ARTIFACT_PRESET", "raccoon_full_art_v1")), ArtifactProvider: strings.ToLower(envString("ARTIFACT_PROVIDER", "openai")), ArtifactModel: envString("ARTIFACT_MODEL", "gpt-image-2"), @@ -284,6 +294,18 @@ func Validate(v Runtime) error { if v.PublicScorePrecision < 0 || v.PublicScorePrecision > 6 { return fmt.Errorf("public_score_precision must be 0..6") } + if v.PlaceEnabled < 0 || v.PlaceEnabled > 1 { + return fmt.Errorf("place_enabled must be 0 or 1") + } + if v.PlaceWidth < 32 || v.PlaceWidth > 1024 || v.PlaceHeight < 32 || v.PlaceHeight > 1024 { + return fmt.Errorf("place dimensions must be 32..1024") + } + if v.PlacePointsPerScore < 1 || v.PlacePointsPerScore > 1000000 { + return fmt.Errorf("place_points_per_score must be 1..1000000") + } + if v.PlacePixelCost < 1 || v.PlacePixelCost > 100000000 { + return fmt.Errorf("place_pixel_cost must be 1..100000000") + } switch strings.ToLower(strings.TrimSpace(v.ArtifactPreset)) { case "legacy", "raccoon_full_art_v1": default: diff --git a/internal/webui/dist/app.js b/internal/webui/dist/app.js index c1350fe..9a880f7 100644 --- a/internal/webui/dist/app.js +++ b/internal/webui/dist/app.js @@ -325,32 +325,32 @@ function userShell(){
TARGET FIELD0 · WEIT75909599+100 · TASK
MAX NODES2000
CHOOSE YOUR FIELD

Wähle deinen Task

Jeder Task ist ein eigener Wahrscheinlichkeitsraum. Du kannst jederzeit wechseln; deine Identität und bereits erreichte Bestwerte bleiben erhalten.

-
DEINE IDENTITÄTinitialisiere …
+
DEINE IDENTITÄTinitialisiere …
PLACE
Tasks werden geladen …
-
Ein Client kann immer nur mit einem Task aktiv verbunden sein.Echtzeit-Leaderboard →
+
Ein Client kann immer nur mit einem Task aktiv verbunden sein.Neural Place → · Echtzeit-Leaderboard →
`; } async function runUser(){ - userShell(); let task=null,points=[],cid='',scheduler=null,countdownTimer=null,ws=null,submitting=false,nextGuessAt=0,refreshing=false,landingBusy=false,landingTimer=null,wsReconnectTimer=null,wsBackoff=500,beaconPath=localStorage.getItem('neuralhunt.beaconPath')||'PULSE'; + userShell(); let task=null,points=[],cid='',scheduler=null,countdownTimer=null,ws=null,submitting=false,nextGuessAt=0,refreshing=false,landingBusy=false,landingTimer=null,wsReconnectTimer=null,wsBackoff=500,beaconPath=localStorage.getItem('neuralhunt.beaconPath')||'PULSE',lastKnownScore=-1,lastPlaceRefresh=0; const map=new NeuralMap($('map'),{panelOffset:-115,onStats:s=>{if($('rendercount'))$('rendercount').textContent=s.render.toLocaleString('de-DE');if($('fpscount'))$('fpscount').textContent=s.fps}}); let detailsOpen=false; const syncMobile=()=>{const on=document.documentElement.classList.contains('mobile-mode');$('toggleMobile').textContent=mobileButtonLabel();setActive('toggleMobile',on);$('signalPanel').classList.toggle('expanded',on&&detailsOpen);if(on){map.eco=true;map.labels=false;map.edges=false;map.zoom=Math.min(map.zoom,.96);setActive('toggleEco',true);setActive('toggleLabels',false);setActive('toggleEdges',false);if(+$('maxnodes').value>1000){$('maxnodes').value=1000;$('maxnodesvalue').textContent='1.000';map.update(points,cid,1000)}}else{map.eco=false;setActive('toggleEco',false)}map.resize()}; @@ -359,7 +359,7 @@ async function runUser(){ const renderRadar=()=>{const rows=proximityRows(points,cid);$('proximityRows').innerHTML=rows.map((p,i)=>`
${p.client_id===cid?'DU':`#${p.rank||i+1}`}
${fmtScore(p.score)}
`).join('')||'
Noch keine Signale
'}; const render=()=>{points=Array.isArray(points)?points:[];const budget=Math.min(10000,Math.max(300,(+$('maxnodes').value||task?.default_max_nodes||2000)*3));if(points.length>budget){const own=points.find(p=>p.client_id===cid),top=points.filter(p=>p.client_id!==cid).sort((a,b)=>Number(b.score||0)-Number(a.score||0)).slice(0,budget-(own?1:0));points=own?[...top,own]:top}if($('nodecount'))$('nodecount').textContent=points.length.toLocaleString('de-DE');if($('clientmetric'))$('clientmetric').textContent=points.length.toLocaleString('de-DE');map.update(points,cid,+$('maxnodes').value);renderRadar()}; const countdown=()=>{let text='—';if(task?.paused)text='PAUSE';else if(task&&nextGuessAt){const sec=Math.max(0,Math.ceil((nextGuessAt-Date.now())/1000));text=sec?`${sec}s`:'jetzt'}$('guessCountdown').textContent=text;if($('guessMobile'))$('guessMobile').textContent=text}; - async function refreshMe(){try{const me=await api('/api/me'),rank='#'+(me.rank||'—'),score=fmtScore(me.score);$('rank').textContent=rank;$('score').textContent=score;if($('rankMobile'))$('rankMobile').textContent=rank;if($('scoreMobile'))$('scoreMobile').textContent=score;$('wins').textContent=me.wins||0;$('unlocks').innerHTML=(me.unlocks||[]).map(x=>`${esc(x)}`).join('')}catch{}} + async function refreshMe(){try{const me=await api('/api/me'),rawScore=Number(me.score||0),rank='#'+(me.rank||'—'),score=fmtScore(rawScore);$('rank').textContent=rank;$('score').textContent=score;if($('rankMobile'))$('rankMobile').textContent=rank;if($('scoreMobile'))$('scoreMobile').textContent=score;$('wins').textContent=me.wins||0;$('unlocks').innerHTML=(me.unlocks||[]).map(x=>`${esc(x)}`).join('');const shouldRefreshPlace=lastKnownScore<0||rawScore>lastKnownScore+1e-9||Date.now()-lastPlaceRefresh>60000;if(shouldRefreshPlace){try{const pl=await api('/api/place/me');if($('placeBadge'))$('placeBadge').textContent=Number(pl?.wallet?.available_pixels||0).toLocaleString('de-DE');lastPlaceRefresh=Date.now()}catch{}}lastKnownScore=rawScore}catch{}} async function refreshLeaders(){try{const raw=await api('/api/leaderboard'),ls=Array.isArray(raw)?raw:[];$('leaders').innerHTML=ls.slice(0,12).map((l,i)=>`
${i+1}${esc(shortID(l.client_id,11))}${l.wins} Wins · live ${fmtScore(l.live_score)}${Number(l.best_score||0).toFixed(1)}
`).join('')||'
Noch keine Teilnehmer
'}catch{}} async function ensureSession(){if(!getToken()){const x=await loginIdentity();setToken(x.token);return}try{const me=await api('/api/me');if(me?.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 stopTimers(){if(scheduler){clearInterval(scheduler);scheduler=null}if(countdownTimer){clearInterval(countdownTimer);countdownTimer=null}nextGuessAt=0;countdown()} @@ -412,7 +412,7 @@ async function runUser(){ function leaderboardShell(){ app.className='leaderboard-page';app.innerHTML=` -
${markHTML()}
NEURAL HUNT / REALTIME LEADERBOARDLive score · wins · watermarked winner NFTs
CLIENT
+
${markHTML()}
NEURAL HUNT / REALTIME LEADERBOARDLive score · wins · watermarked winner NFTs
PUBLIC SIGNAL

Echtzeit-Ranking

Live-Score zeigt die beste Position in einem aktuell aktiven Task. Gewinner-Artefakte werden öffentlich ausschließlich über eine serverseitig erzeugte Vorschau mit Wasserzeichen angezeigt.

verbinde …
@@ -446,6 +446,67 @@ async function runLeaderboard(){ await refresh();const proto=location.protocol==='https:'?'wss':'ws';ws=new WebSocket(`${proto}://${location.host}/api/leaderboard/ws`);ws.onopen=()=>{$('lbState').textContent='LIVE · verbunden'};ws.onmessage=debounce;ws.onclose=()=>{$('lbState').textContent='WebSocket getrennt'};addEventListener('beforeunload',()=>{clearTimeout(refreshTimer);if(ws)ws.close()},{once:true}); } + +function placeShell(){ + app.className='place-page';app.innerHTML=` +
${markHTML()}
NEURAL HUNT / PLACEFortschritt wird zu Farbe · globale Live-Leinwand
verbinde …RANKINGHUNT
+
+ +
+
NEURAL PLACEPixel wählen
0 belegt0 Placements0 Artists
+
Scroll = Zoom · Ziehen = Pan · Klick = Pixel wählen
+
FARBE
AUSWAHLWähle einen Pixel auf der Leinwand.
+
+ +
+ `; +} + +async function runPlace(){ + placeShell(); + let cid='',snapshot=null,config=null,wallet=null,width=0,height=0,palette=[],revision=0,selectedColor=Number(localStorage.getItem('neuralhunt.place.color')||15),selected=null; + let pixels=null,meta=new Map(),filledCount=0,seenFeed=new Set(),off=document.createElement('canvas'),offctx=off.getContext('2d'),canvas=$('placeCanvas'),ctx=canvas.getContext('2d'),scale=1,panX=0,panY=0,dpr=1,drag=null,ws=null,pollTimer=null,walletTimer=null,toastTimer=null; + const pxKey=(x,y)=>y*width+x; + 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()} + 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()} + function applyPixel(p,redraw=true){if(!p||p.x<0||p.y<0||p.x>=width||p.y>=height)return;const k=pxKey(p.x,p.y),ci=Number(p.color_index);if(pixels[k]===255)filledCount++;pixels[k]=ci;meta.set(k,p);offctx.fillStyle=palette[ci]||'#fff';offctx.fillRect(p.x,p.y,1,1);revision=Math.max(revision,Number(p.revision||0));if($('placeRevision'))$('placeRevision').textContent=`REV ${revision.toLocaleString('de-DE')}`;if($('placeFilled'))$('placeFilled').textContent=filledCount.toLocaleString('de-DE');if($('placeTotal'))$('placeTotal').textContent=revision.toLocaleString('de-DE');if(selected&&selected.x===p.x&&selected.y===p.y)updateSelection();if(redraw)draw()} + function viewportRect(){return $('placeViewport').getBoundingClientRect()} + function resize(){const r=viewportRect();dpr=Math.min(2,window.devicePixelRatio||1);const w=Math.max(1,Math.floor(r.width*dpr)),h=Math.max(1,Math.floor(r.height*dpr));if(canvas.width!==w||canvas.height!==h){canvas.width=w;canvas.height=h;canvas.style.width=r.width+'px';canvas.style.height=r.height+'px'}draw()} + function fit(){const r=viewportRect();if(!width||!height||!r.width)return;scale=Math.max(.15,Math.min(r.width/width,r.height/height)*.88);panX=(r.width-width*scale)/2;panY=(r.height-height*scale)/2;draw()} + function boardPos(clientX,clientY){const r=viewportRect(),sx=clientX-r.left,sy=clientY-r.top;return {x:Math.floor((sx-panX)/scale),y:Math.floor((sy-panY)/scale),sx,sy}} + function draw(){if(!pixels)return;const r=viewportRect();ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,r.width,r.height);ctx.save();ctx.translate(panX,panY);ctx.scale(scale,scale);ctx.imageSmoothingEnabled=false;ctx.drawImage(off,0,0);if(scale>=9){ctx.strokeStyle='rgba(255,255,255,.13)';ctx.lineWidth=1/scale;ctx.beginPath();for(let x=0;x<=width;x++){ctx.moveTo(x,0);ctx.lineTo(x,height)}for(let y=0;y<=height;y++){ctx.moveTo(0,y);ctx.lineTo(width,y)}ctx.stroke()}if(selected){ctx.strokeStyle=palette[selectedColor]||'#fff';ctx.lineWidth=Math.max(2/scale,.12);ctx.strokeRect(selected.x+.06,selected.y+.06,.88,.88);ctx.strokeStyle='rgba(255,255,255,.95)';ctx.lineWidth=Math.max(1/scale,.07);ctx.strokeRect(selected.x+.18,selected.y+.18,.64,.64)}ctx.restore()} + function updateSelection(){if(!selected){$('placeSelection').textContent='—';$('placeSelectionOwner').textContent='Wähle einen Pixel auf der Leinwand.';$('placeCoords').textContent='Pixel wählen';updateSubmit();return}const p=meta.get(pxKey(selected.x,selected.y));$('placeSelection').textContent=`X ${selected.x} · Y ${selected.y}`;$('placeCoords').textContent=`${selected.x}, ${selected.y}`;$('placeSelectionOwner').textContent=p?`Aktuell von ${shortID(p.client_id,18)} · Rev ${Number(p.revision||0).toLocaleString('de-DE')}`:'Noch unbelegt';updateSubmit()} + function updateSubmit(){const can=!!selected&&config?.enabled&&Number(wallet?.balance_points||0)>=Number(config.pixel_cost||Infinity);$('placeSubmit').disabled=!can;if(config&&!config.enabled)$('placeSubmit').textContent='PLACE DEAKTIVIERT';else $('placeSubmit').innerHTML=`PIXEL SETZEN · ${fmtPoints(config?.pixel_cost||0)} P`} + function addFeed(p,prepend=true){const rev=Number(p?.revision||0);if(rev&&seenFeed.has(rev))return;if(rev)seenFeed.add(rev);const host=$('placeFeed'),row=document.createElement('div');row.className='place-feed-row';row.innerHTML=`
${esc(shortID(p.client_id,16))}${Number(p.x)}, ${Number(p.y)} · Rev ${Number(p.revision||0).toLocaleString('de-DE')}
`;if(prepend)host.prepend(row);else host.append(row);while(host.children.length>40)host.lastElementChild.remove()} + async function syncChanges(){try{const x=await api(`/api/public/place/changes?after=${revision}`);for(const p of (x.pixels||[])){applyPixel(p,false);addFeed(p)}revision=Math.max(revision,Number(x.revision||0));$('placeRevision').textContent=`REV ${revision.toLocaleString('de-DE')}`;draw()}catch{}} + function openPlaceWS(){const proto=location.protocol==='https:'?'wss':'ws';ws=new WebSocket(`${proto}://${location.host}/api/place/ws`,['neuralhunt.v1']);ws.onopen=()=>{$('placeLive').innerHTML=' LIVE';$('placeLive').classList.add('on')};ws.onmessage=ev=>{try{const e=JSON.parse(ev.data);if(e.type==='place_pixel'){applyPixel(e.data);addFeed(e.data);if(e.data.client_id===cid)refreshWallet()}else if(e.type==='place_ready'&&Number(e.data?.revision||0)>revision)syncChanges()}catch{}};ws.onclose=()=>{$('placeLive').innerHTML=' RECONNECT';$('placeLive').classList.remove('on');setTimeout(()=>{if(document.body.contains(canvas))openPlaceWS()},1200)}} + try{ + await ensurePlaceSession(); + const [pub,me]=await Promise.all([api('/api/public/place'),api('/api/place/me')]);snapshot=pub;config=me.config||pub.config;wallet=me.wallet;width=Number(config.width);height=Number(config.height);palette=config.palette||[];revision=Number(pub.stats?.revision||0);selectedColor=clamp(selectedColor,0,Math.max(0,palette.length-1));renderWallet();renderPalette();initBoard(pub.pixels||[]);$('placeParticipants').textContent=Number(pub.stats?.participants||0).toLocaleString('de-DE');$('placeRevision').textContent=`REV ${revision.toLocaleString('de-DE')}`;for(const p of (pub.recent||[]).slice().reverse())addFeed(p);if(!config.enabled)toast('Neural Place ist aktuell nur lesbar.',true); + }catch(e){$('placeLive').textContent='STARTFEHLER';toast(e.message||'Place konnte nicht geladen werden',true);return} + const viewport=$('placeViewport'); + viewport.onpointerdown=e=>{if(e.button!==0)return;viewport.setPointerCapture(e.pointerId);drag={id:e.pointerId,x:e.clientX,y:e.clientY,px:panX,py:panY,moved:false};canvas.style.cursor='grabbing'}; + viewport.onpointermove=e=>{const b=boardPos(e.clientX,e.clientY);if(drag&&drag.id===e.pointerId){const dx=e.clientX-drag.x,dy=e.clientY-drag.y;if(Math.hypot(dx,dy)>4)drag.moved=true;panX=drag.px+dx;panY=drag.py+dy;draw()}if(b.x>=0&&b.y>=0&&b.x${b.x}, ${b.y}${p?esc(shortID(p.client_id,16)):'frei'}`;h.style.left=clamp(b.sx+14,8,viewport.clientWidth-150)+'px';h.style.top=clamp(b.sy+14,8,viewport.clientHeight-55)+'px';h.classList.remove('hidden')}else $('placeHover').classList.add('hidden')}; + viewport.onpointerup=e=>{if(!drag||drag.id!==e.pointerId)return;const wasMoved=drag.moved;drag=null;canvas.style.cursor='crosshair';if(!wasMoved){const b=boardPos(e.clientX,e.clientY);if(b.x>=0&&b.y>=0&&b.x{drag=null;canvas.style.cursor='crosshair'}; + viewport.onwheel=e=>{e.preventDefault();const r=viewportRect(),mx=e.clientX-r.left,my=e.clientY-r.top,bx=(mx-panX)/scale,by=(my-panY)/scale,f=e.deltaY<0?1.18:.84,newScale=clamp(scale*f,.08,48);panX=mx-bx*newScale;panY=my-by*newScale;scale=newScale;draw()}; + $('placeZoomIn').onclick=()=>{const r=viewportRect(),mx=r.width/2,my=r.height/2,bx=(mx-panX)/scale,by=(my-panY)/scale;scale=clamp(scale*1.35,.08,48);panX=mx-bx*scale;panY=my-by*scale;draw()};$('placeZoomOut').onclick=()=>{const r=viewportRect(),mx=r.width/2,my=r.height/2,bx=(mx-panX)/scale,by=(my-panY)/scale;scale=clamp(scale/1.35,.08,48);panX=mx-bx*scale;panY=my-by*scale;draw()};$('placeFit').onclick=fit; + $('placeSubmit').onclick=async()=>{if(!selected)return;const b=$('placeSubmit');b.disabled=true;try{const out=await api('/api/place/pixel',{method:'POST',body:JSON.stringify({x:selected.x,y:selected.y,color_index:selectedColor})});wallet=out.wallet||wallet;applyPixel(out.pixel);addFeed(out.pixel);renderWallet();if(out.wallet_refresh_required)refreshWallet();toast(`Pixel ${selected.x}, ${selected.y} gesetzt`)}catch(e){toast(e.code==='insufficient_place_points'?'Nicht genug Place-Punkte. Verdiene Fortschritt im Hunt.':e.message||'Placement fehlgeschlagen',true)}finally{updateSubmit()}}; + const onResize=()=>resize();addEventListener('resize',onResize);resize();openPlaceWS();pollTimer=setInterval(syncChanges,5000);walletTimer=setInterval(refreshWallet,5000);addEventListener('beforeunload',()=>{removeEventListener('resize',onResize);clearInterval(pollTimer);clearInterval(walletTimer);if(ws){ws.onclose=null;ws.close()}},{once:true}); +} + function adminLoginShell(){app.className='login';app.innerHTML=``} function adminShell(){ app.className='admin';app.innerHTML=` @@ -465,8 +526,8 @@ async function runAdmin(){ 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']; - 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'}; + 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 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)}}; @@ -482,20 +543,20 @@ async function runAdmin(){ const restoreDraft=()=>{const scope=draftScope(),values=draft.fields?.[scope]||{};document.querySelectorAll('#settingfields input,#settingfields textarea,#settingfields select').forEach(el=>{if(el.type==='file')return;const k=draftFieldKey(el);if(k&&Object.prototype.hasOwnProperty.call(values,k))el.value=values[k]})}; const clearDraftKeys=keys=>{const scope=draftScope(),values=draft.fields?.[scope];if(values){keys.forEach(k=>delete values[k]);saveDraft()}}; function renderOverview(o){$('overview').innerHTML=`${o.connected} verbunden${o.clients} Identitäten${o.active_tasks} aktive Tasks${o.completed_tasks} abgeschlossen${Number(o.guesses||0).toLocaleString('de-DE')} Tipps${o.artifacts_ready} Artefakte`} - function renderPerformance(p){const r=p?.runtime||{},w=p?.websocket||{},x=p?.process||{};$('performance').innerHTML=`${Number(r.guesses_per_sec||0).toFixed(1)} Guess/s${Number(r.rejected_per_sec||0).toFixed(1)} Reject/s${Number(r.improvements_per_sec||0).toFixed(1)} Improve/s${Number(r.sqlite_writes_per_sec||0).toFixed(1)} SQLite W/s${Number(w.frames_per_sec||0).toFixed(0)} WS Frames/s${(Number(w.bytes_per_sec||0)/1048576).toFixed(2)} WS MB/s${Number(w.dropped_per_sec||0).toFixed(1)} Drops/s${Number(x.goroutines||0).toLocaleString('de-DE')} Goroutines${(Number(x.heap_bytes||0)/1048576).toFixed(1)} Heap MB`} + function renderPerformance(p){const r=p?.runtime||{},w=p?.websocket||{},pw=p?.place_websocket||{},x=p?.process||{};$('performance').innerHTML=`${Number(r.guesses_per_sec||0).toFixed(1)} Guess/s${Number(r.rejected_per_sec||0).toFixed(1)} Reject/s${Number(r.improvements_per_sec||0).toFixed(1)} Improve/s${Number(r.sqlite_writes_per_sec||0).toFixed(1)} SQLite W/s${Number(w.frames_per_sec||0).toFixed(0)} WS Frames/s${(Number(w.bytes_per_sec||0)/1048576).toFixed(2)} WS MB/s${Number(w.dropped_per_sec||0).toFixed(1)} Drops/s${Number(pw.connected||0).toLocaleString('de-DE')} Place WS${Number(pw.frames_per_sec||0).toFixed(0)} Place F/s${Number(x.goroutines||0).toLocaleString('de-DE')} Goroutines${(Number(x.heap_bytes||0)/1048576).toFixed(1)} Heap MB`} 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.

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

-
ALTE PROFILE BEREINIGEN

Löscht ausschließlich Accounts, die seit mindestens X Zeit inaktiv, aktuell nicht verbunden und niemals Gewinner eines Tasks waren. Gewinner werden unabhängig vom Alter immer geschützt. Zugehörige Punkte, Unlocks und Task-Auswahl werden mit dem Profil entfernt.

+ $('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“.

+
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'; 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),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 geschützt · ${online.toLocaleString('de-DE')} aktuell verbundene Accounts geschütztGrenze: letzte Aktivität vor ${esc(cutoff)}`;}; + 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}}; $('previewProfileCleanup').onclick=()=>previewCleanup().catch(()=>{}); - $('runProfileCleanup').onclick=async()=>{try{const p=await previewCleanup();const n=Number(p?.eligible||0);if(!n){msg('Keine passenden inaktiven Nicht-Gewinner-Profile gefunden');return}const value=$('profileCleanupValue').value,unitLabel=$('profileCleanupUnit').selectedOptions[0]?.textContent||'';if(!confirm(`${n.toLocaleString('de-DE')} Profile endgültig löschen?\n\nKriterium: seit mindestens ${value} ${unitLabel} inaktiv, offline und niemals Gewinner.\nGewinner und aktuell verbundene Accounts bleiben geschützt.`))return;const out=await api('/api/admin/profiles/cleanup',{method:'POST',body:JSON.stringify({inactive_for_seconds:cleanupSeconds()})},true);msg(`${Number(out.deleted||0).toLocaleString('de-DE')} alte Profile gelöscht`);showCleanup({...p,eligible:Math.max(0,n-Number(out.deleted||0))});await load(true,false)}catch(e){msg(e.message)}}; + $('runProfileCleanup').onclick=async()=>{try{const p=await previewCleanup();const n=Number(p?.eligible||0);if(!n){msg('Keine passenden inaktiven Nicht-Gewinner-Profile gefunden');return}const value=$('profileCleanupValue').value,unitLabel=$('profileCleanupUnit').selectedOptions[0]?.textContent||'';if(!confirm(`${n.toLocaleString('de-DE')} Profile endgültig löschen?\n\nKriterium: seit mindestens ${value} ${unitLabel} inaktiv, offline, niemals Gewinner und ohne Neural-Place-Wert.\nGewinner, Place-Teilnehmer, verknüpfte Worker/Owner und aktuell verbundene Accounts bleiben geschützt.`))return;const out=await api('/api/admin/profiles/cleanup',{method:'POST',body:JSON.stringify({inactive_for_seconds:cleanupSeconds()})},true);msg(`${Number(out.deleted||0).toLocaleString('de-DE')} alte Profile gelöscht`);showCleanup({...p,eligible:Math.max(0,n-Number(out.deleted||0))});await load(true,false)}catch(e){msg(e.message)}}; } function renderArtifact(){ const ps=providers?.providers||{},preset=settings?.artifact_preset||'legacy',u=artifactUsage||{},cb=u.circuit_breaker||{},recent=Array.isArray(u.recent)?u.recent:[]; @@ -589,4 +650,4 @@ async function runAdmin(){ applyMobileMode(mobileModeEnabled()); const mobileMedia=matchMedia('(max-width: 850px)');mobileMedia.addEventListener?.('change',()=>{if(localStorage.getItem(mobileModeKey)===null)applyMobileMode(autoMobileMode())}); const path=location.pathname; -(path.startsWith('/admin')?runAdmin():path.startsWith('/leaderboard')?runLeaderboard():runUser()).catch(e=>{app.innerHTML=`
${esc(e.stack||e.message||e)}
`}); +(path.startsWith('/admin')?runAdmin():path.startsWith('/leaderboard')?runLeaderboard():path.startsWith('/place')?runPlace():runUser()).catch(e=>{app.innerHTML=`
${esc(e.stack||e.message||e)}
`}); diff --git a/internal/webui/dist/styles.css b/internal/webui/dist/styles.css index f03438f..220af70 100644 --- a/internal/webui/dist/styles.css +++ b/internal/webui/dist/styles.css @@ -116,3 +116,16 @@ html.mobile-mode .reference-admin-box,html.mobile-mode .task-style-admin{grid-te .beacon-choice>small{display:block;opacity:.68;line-height:1.35} .admin-drop-box,.ownership-box{margin-top:12px}.admin-drop-box #adminDropResult{margin-top:8px;color:#8edbb8}.task-facts{grid-template-columns:repeat(4,minmax(0,1fr))}@media(max-width:720px){.task-facts{grid-template-columns:repeat(2,minmax(0,1fr))}} .admin-drop-watch{display:grid;gap:5px;margin-top:8px}.admin-drop-watch-row{display:grid;grid-template-columns:78px 112px minmax(0,1fr) auto;gap:7px;align-items:center;padding:7px 8px;border:1px solid rgba(133,200,255,.10);border-radius:9px;background:rgba(255,255,255,.018);font-size:8px}.admin-drop-watch-row b{font-size:7px;letter-spacing:.09em}.admin-drop-watch-row code{font-size:7px;color:#8fbdcf}.admin-drop-watch-row span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#819dac}.admin-drop-watch-row.generating b{color:#8feeff}.admin-drop-watch-row.ready b{color:#7dffc3}.admin-drop-watch-row.error{border-color:rgba(255,95,136,.28)}.admin-drop-watch-row.error b,.admin-drop-watch-row.error span{color:#ff9bad}.admin-drop-watch-row button{padding:5px 7px}@media(max-width:720px){.admin-drop-watch-row{grid-template-columns:70px 1fr}.admin-drop-watch-row span{grid-column:1/-1;white-space:normal}.admin-drop-watch-row button{grid-column:1/-1}} + +/* Neural Place — shared progress-funded pixel canvas. */ +.place-page{min-height:100%;overflow:hidden;background:radial-gradient(circle at 50% 38%,#091929 0,#030812 52%,#010307 100%)} +.place-top{position:fixed;z-index:20;top:16px;left:16px;right:16px;height:64px;border-radius:18px;display:flex;align-items:center;justify-content:space-between;padding:0 18px}.place-nav{display:flex;align-items:center;gap:8px}.place-nav>a{border:1px solid rgba(82,231,255,.15);border-radius:9px;padding:8px 10px;color:#9bc7d8;text-decoration:none;font-size:9px;font-weight:800;letter-spacing:.1em}.place-nav>a:hover{color:#effcff;background:rgba(82,231,255,.07)}.place-live{display:flex;align-items:center;gap:6px;margin-right:7px;color:#7897aa;font-size:8px;font-weight:900;letter-spacing:.12em}.place-live i{width:7px;height:7px;border-radius:50%;background:#617786}.place-live.on{color:var(--green)}.place-live.on i{background:var(--green);box-shadow:0 0 12px var(--green);animation:pulse 1.5s infinite} +.place-layout{position:fixed;inset:96px 16px 16px;display:grid;grid-template-columns:260px minmax(360px,1fr) 230px;gap:12px;min-height:0}.place-side,.place-stage{min-height:0;border-radius:18px}.place-side{padding:16px;overflow:hidden;display:flex;flex-direction:column}.place-kicker,.place-section-head{font-size:8px;font-weight:900;letter-spacing:.13em;color:#708fa2}.place-balance{padding:15px 0 13px;border-bottom:1px solid rgba(133,200,255,.11)}.place-balance>small{display:block;color:#638297;font-size:7px;letter-spacing:.16em}.place-balance>strong{display:block;margin:5px 0 4px;font-size:34px;line-height:1;color:#e9fbff;letter-spacing:-.05em;font-variant-numeric:tabular-nums}.place-balance>span{color:#8aa7b8;font-size:9px}.place-balance>span b{color:var(--cyan)}.place-wallet-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin:12px 0}.place-wallet-grid span{padding:8px;border:1px solid rgba(133,200,255,.09);border-radius:10px;background:rgba(255,255,255,.018)}.place-wallet-grid small{display:block;color:#5f8195;font-size:6px;letter-spacing:.1em}.place-wallet-grid b{display:block;margin-top:4px;font-size:11px;color:#dff8ff}.place-economy{padding:11px;border:1px solid rgba(82,231,255,.12);border-radius:12px;background:rgba(82,231,255,.025)}.place-economy p{margin:7px 0 9px;color:#7899aa;font-size:8px;line-height:1.45}.place-economy>div{display:flex;align-items:baseline;justify-content:space-between;padding:5px 0;border-top:1px solid rgba(133,200,255,.07)}.place-economy>div b{font-size:10px;color:#dffcff}.place-economy>div small{font-size:7px;color:#66879a}.place-workers,.place-earnings{margin-top:14px;min-height:0}.place-earnings{display:flex;flex-direction:column;flex:1}.place-section-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px}.place-section-head small{font-size:7px;color:#5f8094}.place-worker-list{display:flex;gap:5px;flex-wrap:wrap}.place-worker-list code{max-width:100%;overflow:hidden;text-overflow:ellipsis;padding:5px 6px;border-radius:7px;background:rgba(183,117,255,.065);border:1px solid rgba(183,117,255,.13);color:#c8a7eb;font-size:7px}.place-earning-list{overflow:auto;min-height:0;padding-right:3px}.place-earning{display:grid;grid-template-columns:44px 1fr;gap:7px;align-items:center;padding:7px 0;border-bottom:1px solid rgba(133,200,255,.07)}.place-source{font-size:6px;font-weight:900;letter-spacing:.08em;color:#8feeff}.place-source.worker{color:#c7a1ff}.place-earning b{font-size:9px;color:#dffff3}.place-earning small{display:block;margin-top:2px;color:#628397;font-size:7px;line-height:1.35} +.place-stage{display:grid;grid-template-rows:52px minmax(0,1fr) auto;overflow:hidden;background:rgba(3,9,17,.72)}.place-canvas-toolbar{display:grid;grid-template-columns:minmax(130px,1fr) auto auto;gap:14px;align-items:center;padding:0 13px;border-bottom:1px solid rgba(133,200,255,.1)}.place-canvas-toolbar>div:first-child b{display:block;margin-top:3px;font-size:10px;color:#dffaff}.place-canvas-stats{display:flex;gap:14px;color:#68889a;font-size:7px}.place-canvas-stats b{color:#cfeaf3;font-size:9px}.place-tools{display:flex;gap:4px}.place-tools button{min-width:34px;padding:6px 8px}.place-viewport{position:relative;min-height:0;overflow:hidden;background-color:#07101a;background-image:linear-gradient(45deg,rgba(255,255,255,.018) 25%,transparent 25%),linear-gradient(-45deg,rgba(255,255,255,.018) 25%,transparent 25%),linear-gradient(45deg,transparent 75%,rgba(255,255,255,.018) 75%),linear-gradient(-45deg,transparent 75%,rgba(255,255,255,.018) 75%);background-size:20px 20px;background-position:0 0,0 10px,10px -10px,-10px 0}.place-viewport canvas{position:absolute;inset:0;width:100%;height:100%;display:block;cursor:crosshair;touch-action:none}.place-hint{position:absolute;left:50%;bottom:10px;transform:translateX(-50%);pointer-events:none;padding:6px 9px;border:1px solid rgba(133,200,255,.1);background:rgba(2,7,13,.75);border-radius:999px;color:#628398;font-size:7px;letter-spacing:.04em;white-space:nowrap}.place-hover{position:absolute;pointer-events:none;z-index:5;min-width:85px;padding:6px 8px;border:1px solid rgba(82,231,255,.2);border-radius:8px;background:rgba(2,7,14,.94);box-shadow:0 8px 22px rgba(0,0,0,.35)}.place-hover b{display:block;color:#ecfbff;font-size:9px}.place-hover small{display:block;margin-top:2px;color:#7493a5;font-size:7px} +.place-compose{display:grid;grid-template-columns:minmax(220px,1.35fr) minmax(150px,.8fr) auto;gap:12px;align-items:end;padding:12px;border-top:1px solid rgba(133,200,255,.1);background:rgba(2,7,14,.72)}.place-palette{display:grid;grid-template-columns:repeat(16,minmax(13px,1fr));gap:4px;margin-top:6px}.place-swatch{aspect-ratio:1;padding:2px!important;border-radius:5px!important;border-color:rgba(255,255,255,.09)!important;background:transparent!important}.place-swatch i{display:block;width:100%;height:100%;border-radius:3px;background:var(--swatch);box-shadow:inset 0 0 0 1px rgba(255,255,255,.13)}.place-swatch.active{border-color:#fff!important;box-shadow:0 0 0 1px rgba(82,231,255,.8),0 0 13px rgba(82,231,255,.28)}.place-selection{min-width:0;padding:8px 10px;border:1px solid rgba(133,200,255,.09);border-radius:10px;background:rgba(255,255,255,.018)}.place-selection small{display:block;font-size:6px;color:#648398;letter-spacing:.11em}.place-selection b{display:block;margin-top:3px;font-size:10px}.place-selection span{display:block;margin-top:3px;color:#718f9f;font-size:7px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.place-compose>button{height:42px;padding:0 15px;color:#c9fff0;border-color:rgba(93,255,189,.28);background:rgba(93,255,189,.07)}.place-compose>button:not(:disabled):hover{background:rgba(93,255,189,.13)}.place-compose>button span{color:#7cbba4;font-size:8px} +.place-right{padding-right:10px}.place-feed{overflow:auto;min-height:0;flex:1;padding-right:4px}.place-feed-row{display:grid;grid-template-columns:12px 1fr;gap:8px;align-items:center;padding:7px 0;border-bottom:1px solid rgba(133,200,255,.065)}.place-feed-row>i{width:11px;height:11px;border-radius:3px;background:var(--feed-color);box-shadow:0 0 10px color-mix(in srgb,var(--feed-color) 40%,transparent)}.place-feed-row b{display:block;font-size:8px;color:#cfe5ef}.place-feed-row small{display:block;margin-top:2px;font-size:7px;color:#607f91}.place-rules{margin-top:11px;padding:10px;border:1px solid rgba(255,180,82,.12);border-radius:10px;background:rgba(255,180,82,.025)}.place-rules p{margin:6px 0 0;color:#7f92a0;font-size:7px;line-height:1.45}.place-toast{position:fixed;z-index:50;left:50%;bottom:24px;transform:translateX(-50%);padding:10px 14px;border:1px solid rgba(93,255,189,.28);border-radius:10px;background:rgba(4,20,17,.95);color:#bfffe4;font-size:9px;font-weight:800;box-shadow:0 14px 40px rgba(0,0,0,.45)}.place-toast.bad{border-color:rgba(255,95,136,.32);background:rgba(25,6,12,.96);color:#ffb0c2} +@media(max-width:1180px){.place-layout{grid-template-columns:220px minmax(340px,1fr)}.place-right{display:none}.place-palette{grid-template-columns:repeat(8,1fr)}.place-compose{grid-template-columns:minmax(180px,1fr) minmax(145px,.8fr) auto}} +@media(max-width:820px){.place-page{overflow:auto}.place-top{top:8px;left:8px;right:8px;height:58px;padding:0 11px}.place-top .brand small{display:none}.place-nav{gap:4px}.place-live{display:none}.place-nav>a{padding:7px;font-size:7px}.place-layout{position:absolute;inset:78px 8px auto;display:flex;flex-direction:column;gap:8px;padding-bottom:8px}.place-stage{order:1;height:72vh;min-height:520px}.place-left{order:2;overflow:visible}.place-right{display:flex;order:3;max-height:380px}.place-canvas-toolbar{grid-template-columns:1fr auto}.place-canvas-stats{display:none}.place-compose{grid-template-columns:1fr;align-items:stretch}.place-palette{grid-template-columns:repeat(16,1fr)}.place-selection{display:none}.place-compose>button{width:100%}.place-side{padding:13px}.place-earnings{max-height:240px}.place-hint{font-size:6px}.place-balance>strong{font-size:28px}} +@media(max-width:520px){.place-stage{height:68vh;min-height:470px}.place-palette{grid-template-columns:repeat(8,1fr);gap:5px}.place-canvas-toolbar{height:48px;padding:0 8px}.place-tools button{min-width:30px;padding:5px}.place-compose{padding:9px}.place-layout{}.place-top .brand strong{font-size:10px}.place-nav>a:first-of-type{display:none}} +.signal-metrics{grid-template-columns:repeat(4,1fr)} +.identity-actions{flex-wrap:wrap}.identity-actions>*{min-width:calc(33.333% - 6px)}