RC-15-PLACE
All checks were successful
release-tag / release-image (push) Successful in 3m56s

This commit is contained in:
2026-08-14 10:13:57 +02:00
parent 47c523dd98
commit 52d33b25fd
13 changed files with 1008 additions and 43 deletions

View File

@@ -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

View File

@@ -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=<rev>` 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`.

95
V4.4_NEURAL_PLACE.md Normal file
View File

@@ -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=<revision>
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.

325
internal/data/place.go Normal file
View File

@@ -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
}

107
internal/data/place_test.go Normal file
View File

@@ -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)
}
}

View File

@@ -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);

View File

@@ -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<?`, now, id, cutoff)
}
// Presence lease helpers replace Redis SET NX + TTL.
func (s *Store) AcquirePresence(ctx context.Context, cid, sessionID string, ttl time.Duration) error {
tx, err := s.DB.BeginTx(ctx, nil)
@@ -1861,7 +1883,7 @@ func (s *Store) LoadGuessState(ctx context.Context, taskID, cid string) (GuessSt
// sequence/count values include all losing guesses that happened in memory
// since the previous checkpoint, so a restart resumes from the latest durable
// improvement rather than writing every false guess.
func (s *Store) PersistImprovement(ctx context.Context, t SecretTask, cid, rewardOwner string, nextSeq, guessCount int64, lastGuess time.Time, score float64, guess, sig string, correct bool, beaconPath, beaconBoostedPath string, beaconRound uint64) (Point, error) {
func (s *Store) PersistImprovement(ctx context.Context, t SecretTask, cid, rewardOwner string, nextSeq, guessCount int64, lastGuess time.Time, score float64, guess, sig string, correct bool, beaconPath, beaconBoostedPath string, beaconRound uint64, placePointsPerScore int) (Point, error) {
tx, err := s.DB.BeginTx(ctx, nil)
if err != nil {
return Point{}, err
@@ -1878,6 +1900,10 @@ func (s *Store) PersistImprovement(ctx context.Context, t SecretTask, cid, rewar
if paused != 0 {
return Point{}, ErrTaskPaused
}
var oldScore float64
if err := tx.QueryRowContext(ctx, `SELECT score FROM task_points WHERE task_id=? AND client_id=?`, t.ID, cid).Scan(&oldScore); err != nil && !errors.Is(err, sql.ErrNoRows) {
return Point{}, err
}
x, y, z := core.Position(cid, score)
lastMS := lastGuess.UTC().UnixMilli()
_, err = tx.ExecContext(ctx, `INSERT INTO task_points(task_id,client_id,score,x,y,z,guess_count,next_seq,last_guess_at)
@@ -1887,6 +1913,11 @@ func (s *Store) PersistImprovement(ctx context.Context, t SecretTask, cid, rewar
if err != nil {
return Point{}, err
}
if score > 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

159
internal/server/place.go Normal file
View File

@@ -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
}
}
}
}

View File

@@ -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")
}
}

View File

@@ -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

View File

@@ -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:

View File

@@ -325,32 +325,32 @@ function userShell(){
<aside class="signal-panel glass" id="signalPanel">
<div class="panel-title"><span>DEIN SIGNAL</span><span class="chip" id="guessCountdown">—</span></div>
<div class="rank-hero"><small>RANK</small><strong id="rank">#—</strong></div>
<div class="signal-metrics"><div><span>Score</span><b id="score">0.00</b></div><div><span>Wins</span><b id="wins">0</b></div><div><span>Clients</span><b id="clientmetric">0</b></div></div>
<div class="signal-metrics"><div><span>Score</span><b id="score">0.00</b></div><div><span>Wins</span><b id="wins">0</b></div><div><span>Place PX</span><b id="placeBadge">0</b></div><div><span>Clients</span><b id="clientmetric">0</b></div></div>
<div id="beaconChoice" class="beacon-choice hidden"><div class="leaderboard-head"><span>BEACON PATH</span><small id="beaconMeta">externer Zufallsimpuls</small></div><div class="beacon-buttons"><button data-beacon-path="PULSE">PULSE</button><button data-beacon-path="FLUX">FLUX</button><button data-beacon-path="ORBIT">ORBIT</button></div><small id="beaconLast">Wähle vor dem nächsten Los einen Pfad.</small></div>
<div class="proximity-mini"><div class="leaderboard-head"><span>TARGET RADAR</span><small>100 = Task</small></div><div id="proximityRows"></div></div>
<div class="identity-block"><span class="eyebrow">IDENTITÄT</span><code id="cid">…</code><div class="identity-actions"><button id="exportid">SICHERN</button><label class="button">IMPORT<input id="importid" hidden type="file" accept="application/json"></label><button id="mynfts">MEINE NFTS</button><button id="hostedCode">HOSTED CODE</button></div><div id="myNftsPanel" class="identity-nfts hidden"></div><div class="chips" id="unlocks"></div></div>
<div class="identity-block"><span class="eyebrow">IDENTITÄT</span><code id="cid">…</code><div class="identity-actions"><button id="exportid">SICHERN</button><label class="button">IMPORT<input id="importid" hidden type="file" accept="application/json"></label><button id="mynfts">MEINE NFTS</button><a class="button" href="/place">PLACE</a><button id="hostedCode">HOSTED CODE</button></div><div id="myNftsPanel" class="identity-nfts hidden"></div><div class="chips" id="unlocks"></div></div>
<div class="leaderboard-head"><span>LEADERBOARD</span><a href="/leaderboard">ECHTZEIT →</a></div><div class="leaderboard" id="leaders"></div>
</aside>
<div class="distance-legend glass"><b>TARGET FIELD</b><span>0 · WEIT</span><i></i><span>75</span><span>90</span><span>95</span><span>99+</span><span>100 · TASK</span></div>
<div class="node-limit glass"><span>MAX NODES</span><input id="maxnodes" type="range" min="100" max="25000" step="100" value="2000"><b id="maxnodesvalue">2000</b></div>
<nav class="dock glass" aria-label="3D-Steuerung">
<button id="chooseTask">TASKS</button><button id="toggleMobile">MOBILE</button><button id="toggleDetails">DETAILS</button><button id="toggleProximity" class="active">TARGET FIELD</button><button id="toggleRotate" class="active">ORBIT</button><button id="toggleLabels" class="active">LABELS</button><button id="toggleEdges" class="active">SIGNALWEGE</button><button id="toggleShells" class="active">SCORE-RINGE</button><button id="toggleLOD" class="active">LOD</button><button id="toggleEco">ECO</button><button id="resetView">ZENTRIEREN</button><a class="dock-link" href="/leaderboard">RANKING</a>
<button id="chooseTask">TASKS</button><button id="toggleMobile">MOBILE</button><button id="toggleDetails">DETAILS</button><button id="toggleProximity" class="active">TARGET FIELD</button><button id="toggleRotate" class="active">ORBIT</button><button id="toggleLabels" class="active">LABELS</button><button id="toggleEdges" class="active">SIGNALWEGE</button><button id="toggleShells" class="active">SCORE-RINGE</button><button id="toggleLOD" class="active">LOD</button><button id="toggleEco">ECO</button><button id="resetView">ZENTRIEREN</button><a class="dock-link" href="/place">PLACE</a><a class="dock-link" href="/leaderboard">RANKING</a>
</nav>
<section id="taskLanding" class="task-landing visible">
<div class="task-landing-bg"></div>
<div class="task-landing-inner">
<div class="task-landing-head">
<div><span class="eyebrow">CHOOSE YOUR FIELD</span><h1>Wähle deinen Task</h1><p>Jeder Task ist ein eigener Wahrscheinlichkeitsraum. Du kannst jederzeit wechseln; deine Identität und bereits erreichte Bestwerte bleiben erhalten.</p></div>
<div class="task-landing-id glass"><span>DEINE IDENTITÄT</span><code id="landingCid">initialisiere …</code><div class="landing-identity-actions"><button id="landingExportId">SICHERN</button><label class="button">IMPORT<input id="landingImportId" hidden type="file" accept="application/json"></label><button id="landingMyNfts">MEINE NFTS</button><button id="landingHostedCode">HOSTED CODE</button></div><div id="landingNftsPanel" class="landing-owned-nfts hidden"></div><button id="landingRefresh">AKTUALISIEREN</button></div>
<div class="task-landing-id glass"><span>DEINE IDENTITÄT</span><code id="landingCid">initialisiere …</code><div class="landing-identity-actions"><button id="landingExportId">SICHERN</button><label class="button">IMPORT<input id="landingImportId" hidden type="file" accept="application/json"></label><button id="landingMyNfts">MEINE NFTS</button><a class="button" href="/place">PLACE</a><button id="landingHostedCode">HOSTED CODE</button></div><div id="landingNftsPanel" class="landing-owned-nfts hidden"></div><button id="landingRefresh">AKTUALISIEREN</button></div>
</div>
<div id="taskCards" class="task-cards"><div class="task-card-loading">Tasks werden geladen …</div></div>
<div class="task-landing-foot"><span>Ein Client kann immer nur mit <b>einem</b> Task aktiv verbunden sein.</span><a href="/leaderboard">Echtzeit-Leaderboard →</a></div>
<div class="task-landing-foot"><span>Ein Client kann immer nur mit <b>einem</b> Task aktiv verbunden sein.</span><span><a href="/place">Neural Place →</a> · <a href="/leaderboard">Echtzeit-Leaderboard →</a></span></div>
</div>
</section>`;
}
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)=>`<div class="proximity-row ${p.client_id===cid?'self':''}"><span>${p.client_id===cid?'DU':`#${p.rank||i+1}`}</span><div><i style="width:${clamp(Number(p.score||0),0,100)}%"></i></div><b>${fmtScore(p.score)}</b></div>`).join('')||'<div class="empty small">Noch keine Signale</div>'};
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=>`<span>${esc(x)}</span>`).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=>`<span>${esc(x)}</span>`).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)=>`<div class="leader-row ${l.client_id===cid?'self':''}"><span class="leader-rank">${i+1}</span><span><b>${esc(shortID(l.client_id,11))}</b><small>${l.wins} Wins · live ${fmtScore(l.live_score)}</small></span><strong>${Number(l.best_score||0).toFixed(1)}</strong></div>`).join('')||'<div class="empty">Noch keine Teilnehmer</div>'}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=`
<header class="leaderboard-top glass"><div class="brand">${markHTML()}<div><strong>NEURAL HUNT / REALTIME LEADERBOARD</strong><small>Live score · wins · watermarked winner NFTs</small></div></div><div class="leaderboard-nav"><button id="lbMobile">MOBILE</button><a href="/">CLIENT</a></div></header>
<header class="leaderboard-top glass"><div class="brand">${markHTML()}<div><strong>NEURAL HUNT / REALTIME LEADERBOARD</strong><small>Live score · wins · watermarked winner NFTs</small></div></div><div class="leaderboard-nav"><button id="lbMobile">MOBILE</button><a href="/place">PLACE</a><a href="/">CLIENT</a></div></header>
<main class="lb-main">
<section class="lb-hero"><div class="lb-title"><span class="eyebrow">PUBLIC SIGNAL</span><h1>Echtzeit-Ranking</h1><p>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.</p></div><div class="lb-controls glass"><div class="segmented"><button id="lbLive" class="active">LIVE</button><button id="lbAll">ALL-TIME</button></div><input id="lbSearch" placeholder="Client-ID / Task suchen"><span id="lbState">verbinde …</span></div></section>
<section id="lbPodium" class="lb-podium"></section>
@@ -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=`
<header class="place-top glass"><div class="brand">${markHTML()}<div><strong>NEURAL HUNT / PLACE</strong><small>Fortschritt wird zu Farbe · globale Live-Leinwand</small></div></div><div class="place-nav"><span id="placeLive" class="place-live"><i></i> verbinde …</span><a href="/leaderboard">RANKING</a><a href="/">HUNT</a></div></header>
<main class="place-layout">
<aside class="place-side place-left glass">
<div class="place-kicker">DEIN PLACE WALLET</div>
<div class="place-balance"><small>PUNKTE</small><strong id="placePoints">0</strong><span><b id="placePixelsAvailable">0</b> Pixel verfügbar</span></div>
<div class="place-wallet-grid"><span><small>ERHALTEN</small><b id="placeEarned">0</b></span><span><small>AUSGEGEBEN</small><b id="placeSpent">0</b></span><span><small>PLACEMENTS</small><b id="placePlacements">0</b></span><span><small>AKTUELL DEINE</small><b id="placeOwned">0</b></span></div>
<div class="place-economy"><span class="eyebrow">ÖKONOMIE</span><p id="placeEconomy">Hunt-Fortschritt erzeugt Place-Punkte.</p><div><b id="placeRate">—</b><small>Punkte pro +1,00 %</small></div><div><b id="placeCost">—</b><small>Punkte pro Pixel</small></div></div>
<div class="place-workers"><div class="place-section-head"><span>VERKNÜPFTE WORKER</span><small id="placeWorkerCount">0</small></div><div id="placeWorkerList" class="place-worker-list"><span class="empty small">keine</span></div></div>
<div class="place-earnings"><div class="place-section-head"><span>LETZTE FORTSCHRITTE</span><small>HUNT → PLACE</small></div><div id="placeEarnings" class="place-earning-list"></div></div>
</aside>
<section class="place-stage glass">
<div class="place-canvas-toolbar"><div><span class="eyebrow">NEURAL PLACE</span><b id="placeCoords">Pixel wählen</b></div><div class="place-canvas-stats"><span><b id="placeFilled">0</b> belegt</span><span><b id="placeTotal">0</b> Placements</span><span><b id="placeParticipants">0</b> Artists</span></div><div class="place-tools"><button id="placeZoomOut"></button><button id="placeFit">FIT</button><button id="placeZoomIn"></button></div></div>
<div id="placeViewport" class="place-viewport"><canvas id="placeCanvas"></canvas><div id="placeHover" class="place-hover hidden"></div><div class="place-hint">Scroll = Zoom · Ziehen = Pan · Klick = Pixel wählen</div></div>
<div class="place-compose"><div><span class="eyebrow">FARBE</span><div id="placePalette" class="place-palette"></div></div><div class="place-selection"><small>AUSWAHL</small><b id="placeSelection">—</b><span id="placeSelectionOwner">Wähle einen Pixel auf der Leinwand.</span></div><button id="placeSubmit" disabled>PIXEL SETZEN <span id="placeSubmitCost"></span></button></div>
</section>
<aside class="place-side place-right glass"><div class="place-section-head"><span>LIVE FEED</span><small id="placeRevision">REV 0</small></div><div id="placeFeed" class="place-feed"></div><div class="place-rules"><span class="eyebrow">REGEL</span><p>Kein Zeit-Cooldown. Die knappe Ressource ist echter Hunt-Fortschritt. Eigene Fortschritte und Fortschritte deiner verknüpften Worker landen im selben Wallet.</p></div></aside>
</main>
<div id="placeToast" class="place-toast hidden"></div>`;
}
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=>`<code title="${esc(x)}">${esc(shortID(x,18))}</code>`).join(''):'<span class="empty small">Keine Worker verknüpft</span>';$('placeEarnings').innerHTML=(wallet.recent_earnings||[]).map(e=>{const worker=e.source_client_id!==cid;return `<div class="place-earning"><span class="place-source ${worker?'worker':''}">${worker?'WORKER':'DU'}</span><div><b>+${fmtPoints(e.points)} P</b><small>${Number(e.old_score||0).toFixed(2)}${Number(e.new_score||0).toFixed(2)} % · ${esc(shortID(e.task_id?.slice(-10),10))}</small></div></div>`}).join('')||'<span class="empty small">Noch kein vergüteter Fortschritt.</span>';updateSubmit()}
async function refreshWallet(){try{const x=await api('/api/place/me');config=x.config||config;wallet=x.wallet||wallet;renderWallet()}catch(e){if(e.status===401)return;}}
function renderPalette(){$('placePalette').innerHTML=palette.map((c,i)=>`<button class="place-swatch ${i===selectedColor?'active':''}" data-place-color="${i}" style="--swatch:${c}" aria-label="Farbe ${i+1}"><i></i></button>`).join('');document.querySelectorAll('[data-place-color]').forEach(b=>b.onclick=()=>{selectedColor=Number(b.dataset.placeColor);localStorage.setItem('neuralhunt.place.color',String(selectedColor));renderPalette();draw();updateSubmit()})}
function initBoard(list){pixels=new Uint8Array(width*height);pixels.fill(255);meta=new Map();filledCount=0;off.width=width;off.height=height;offctx.imageSmoothingEnabled=false;offctx.fillStyle='#f5f5f5';offctx.fillRect(0,0,width,height);for(const p of (list||[]))applyPixel(p,false);fit();draw()}
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 <span id="placeSubmitCost">· ${fmtPoints(config?.pixel_cost||0)} P</span>`}
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=`<i style="--feed-color:${palette[Number(p.color_index)]||'#fff'}"></i><div><b>${esc(shortID(p.client_id,16))}</b><small>${Number(p.x)}, ${Number(p.y)} · Rev ${Number(p.revision||0).toLocaleString('de-DE')}</small></div>`;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='<i></i> 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='<i></i> 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<width&&b.y<height){const p=meta.get(pxKey(b.x,b.y)),h=$('placeHover');h.innerHTML=`<b>${b.x}, ${b.y}</b><small>${p?esc(shortID(p.client_id,16)):'frei'}</small>`;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<width&&b.y<height){selected={x:b.x,y:b.y};updateSelection();draw()}}};
viewport.onpointercancel=()=>{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=`<div class="login-card glass"><div class="brand">${markHTML()}<div><strong>NEURAL HUNT / ADMIN</strong><small>Control plane</small></div></div><input id="adminuser" value="admin" placeholder="Benutzer"><input id="adminpass" type="password" placeholder="Passwort"><button id="adminlogin">ANMELDEN</button><p class="danger statusline" id="adminerr"></p></div>`}
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 (110)',sybil_pow_bits:'Anti-Sybil Proof-of-Work (Bit, 0 = aus)',sybil_warmup_sec:'Neue Identität Wartezeit (s)',openai_max_calls_1h:'OpenAI max. Bild-Calls / 1h (0 = aus)',openai_max_calls_24h:'OpenAI max. Bild-Calls / 24h (0 = aus)',openai_max_cost_24h_usd:'OpenAI max. geschätzte Kosten / 24h USD (0 = aus)',openai_budget_reserve_usd:'OpenAI Sicherheitsreserve pro nächstem Call USD',task_range_bits:'Default Zahlenraum (Bit)',active_task_count:'Parallele aktive Tasks',presence_ttl_sec:'Presence TTL (s)',default_max_nodes:'Default Max Nodes',public_score_precision:'Öffentliche Score-Präzision'};
const runtimeKeys=['guess_min_interval_sec','client_submit_interval_sec','guess_lottery_window_sec','guess_lottery_max_accepted','beacon_hunt_enabled','beacon_bonus_weight','sybil_pow_bits','sybil_warmup_sec','openai_max_calls_1h','openai_max_calls_24h','openai_max_cost_24h_usd','openai_budget_reserve_usd','task_range_bits','active_task_count','presence_ttl_sec','default_max_nodes','public_score_precision','place_enabled','place_width','place_height','place_points_per_score','place_pixel_cost'];
const labels={guess_min_interval_sec:'Server-Tippintervall (s)',client_submit_interval_sec:'Client-Tippintervall (s)',guess_lottery_window_sec:'Lotterie-Zeitfenster (s)',guess_lottery_max_accepted:'Max. gezogene Tipps je Task/Fenster (0 = aus)',beacon_hunt_enabled:'Beacon Hunt (0 = aus, 1 = an)',beacon_bonus_weight:'Beacon Treffer-Gewicht (110)',sybil_pow_bits:'Anti-Sybil Proof-of-Work (Bit, 0 = aus)',sybil_warmup_sec:'Neue Identität Wartezeit (s)',openai_max_calls_1h:'OpenAI max. Bild-Calls / 1h (0 = aus)',openai_max_calls_24h:'OpenAI max. Bild-Calls / 24h (0 = aus)',openai_max_cost_24h_usd:'OpenAI max. geschätzte Kosten / 24h USD (0 = aus)',openai_budget_reserve_usd:'OpenAI Sicherheitsreserve pro nächstem Call USD',task_range_bits:'Default Zahlenraum (Bit)',active_task_count:'Parallele aktive Tasks',presence_ttl_sec:'Presence TTL (s)',default_max_nodes:'Default Max Nodes',public_score_precision:'Öffentliche Score-Präzision',place_enabled:'Neural Place (0 = aus, 1 = an)',place_width:'Place Breite (Pixel)',place_height:'Place Höhe (Pixel)',place_points_per_score:'Place-Punkte je +1,00 Score',place_pixel_cost:'Place-Kosten je Pixel'};
const msg=s=>$('adminstatus').textContent=s;
const renderAdminDropWatch=items=>{const host=$('adminDropResult');if(!host)return;const rows=Array.isArray(items)?items:[];if(!rows.length){host.textContent=adminDropWatchIDs.length?'Queue-Einträge werden gesucht …':'';return}const order={error:0,generating:1,pending:2,ready:3};rows.sort((a,b)=>(order[a.artifact_status]??9)-(order[b.artifact_status]??9));host.innerHTML=`<div class="admin-drop-watch">${rows.map(x=>{const st=String(x.artifact_status||'unknown'),err=String(x.artifact_error||'').trim(),id=String(x.task_id||'');let info=st==='pending'?'wartet auf Artifact-Worker':st==='generating'?'Bild wird gerade erzeugt':st==='ready'?'Collectible fertig':st==='error'?(err||'Generierung fehlgeschlagen'):st;return `<div class="admin-drop-watch-row ${esc(st)}"><b>${esc(st.toUpperCase())}</b><code>${esc(id.slice(-14))}</code>${(x.artifact_rarity||x.artifact_rarity_override)?`<small>${esc(x.artifact_rarity||x.artifact_rarity_override)}</small>`:''}<span title="${esc(err)}">${esc(info.length>180?info.slice(0,179)+'…':info)}</span>${st==='ready'?`<button type="button" data-drop-open="${esc(id)}">ÖFFNEN</button>`:''}</div>`}).join('')}</div>`;host.querySelectorAll('[data-drop-open]').forEach(b=>b.onclick=()=>openAdminFile(b.dataset.dropOpen,'artifact'))};
const refreshAdminDropWatch=async()=>{clearTimeout(adminDropWatchTimer);adminDropWatchTimer=null;if(!adminDropWatchIDs.length)return;try{const rows=await api(`/api/admin/artifacts/status?ids=${encodeURIComponent(adminDropWatchIDs.join(','))}`,{},true);renderAdminDropWatch(rows);const active=(rows||[]).some(x=>x.artifact_status==='pending'||x.artifact_status==='generating');if(active){adminDropWatchTimer=setTimeout(refreshAdminDropWatch,2000)}else{adminDropWatchIDs=[];setTimeout(()=>load(true,false),0)}}catch(e){const host=$('adminDropResult');if(host)host.textContent='Queue-Status konnte nicht geladen werden: '+(e.message||e);adminDropWatchTimer=setTimeout(refreshAdminDropWatch,4000)}};
@@ -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=`<span><b>${o.connected}</b> verbunden</span><span><b>${o.clients}</b> Identitäten</span><span><b>${o.active_tasks}</b> aktive Tasks</span><span><b>${o.completed_tasks}</b> abgeschlossen</span><span><b>${Number(o.guesses||0).toLocaleString('de-DE')}</b> Tipps</span><span><b>${o.artifacts_ready}</b> Artefakte</span>`}
function renderPerformance(p){const r=p?.runtime||{},w=p?.websocket||{},x=p?.process||{};$('performance').innerHTML=`<span><b>${Number(r.guesses_per_sec||0).toFixed(1)}</b> Guess/s</span><span><b>${Number(r.rejected_per_sec||0).toFixed(1)}</b> Reject/s</span><span><b>${Number(r.improvements_per_sec||0).toFixed(1)}</b> Improve/s</span><span><b>${Number(r.sqlite_writes_per_sec||0).toFixed(1)}</b> SQLite W/s</span><span><b>${Number(w.frames_per_sec||0).toFixed(0)}</b> WS Frames/s</span><span><b>${(Number(w.bytes_per_sec||0)/1048576).toFixed(2)}</b> WS MB/s</span><span><b>${Number(w.dropped_per_sec||0).toFixed(1)}</b> Drops/s</span><span><b>${Number(x.goroutines||0).toLocaleString('de-DE')}</b> Goroutines</span><span><b>${(Number(x.heap_bytes||0)/1048576).toFixed(1)}</b> Heap MB</span>`}
function renderPerformance(p){const r=p?.runtime||{},w=p?.websocket||{},pw=p?.place_websocket||{},x=p?.process||{};$('performance').innerHTML=`<span><b>${Number(r.guesses_per_sec||0).toFixed(1)}</b> Guess/s</span><span><b>${Number(r.rejected_per_sec||0).toFixed(1)}</b> Reject/s</span><span><b>${Number(r.improvements_per_sec||0).toFixed(1)}</b> Improve/s</span><span><b>${Number(r.sqlite_writes_per_sec||0).toFixed(1)}</b> SQLite W/s</span><span><b>${Number(w.frames_per_sec||0).toFixed(0)}</b> WS Frames/s</span><span><b>${(Number(w.bytes_per_sec||0)/1048576).toFixed(2)}</b> WS MB/s</span><span><b>${Number(w.dropped_per_sec||0).toFixed(1)}</b> Drops/s</span><span><b>${Number(pw.connected||0).toLocaleString('de-DE')}</b> Place WS</span><span><b>${Number(pw.frames_per_sec||0).toFixed(0)}</b> Place F/s</span><span><b>${Number(x.goroutines||0).toLocaleString('de-DE')}</b> Goroutines</span><span><b>${(Number(x.heap_bytes||0)/1048576).toFixed(1)}</b> Heap MB</span>`}
async function openAdminFile(taskID,kind){const popup=window.open('','_blank');try{const r=await fetch(`/api/admin/tasks/${encodeURIComponent(taskID)}/${kind}`,{credentials:'same-origin'});if(!r.ok)throw new Error(await responseError(r,'Datei konnte nicht geöffnet werden'));const blob=await r.blob(),u=URL.createObjectURL(blob);if(popup)popup.location=u;else{const a=document.createElement('a');a.href=u;a.target='_blank';a.click()}setTimeout(()=>URL.revokeObjectURL(u),60000)}catch(e){if(popup)popup.close();msg(e.message)}}
function renderTasks(){tasks=Array.isArray(tasks)?tasks:[];$('taskCount').textContent=tasks.length;$('tasks').innerHTML=tasks.length?tasks.map(t=>{const aerr=String(t.artifact_error||'').trim();return `<button data-id="${esc(t.id)}" class="${selected?.id===t.id?'selected':''}"><span><b>${esc(t.display_name||t.id.slice(-12))}</b><small>${esc(t.id.slice(-12))} · ${fmtDate(t.created_at)}</small></span><span><em class="task-state ${esc(t.status)}">${t.paused?'PAUSED':esc(t.status)}</em><small>${t.range_bits} Bit · rev ${t.revision} · ${Number(t.point_count||0).toLocaleString('de-DE')} Clients · ${Number(t.guess_count||0).toLocaleString('de-DE')} Tipps</small></span><span class="artifact-state-wrap"><b class="artifact-state ${esc(t.artifact_status||'')}">${esc(t.artifact_status||'—')}</b>${aerr?`<small class="danger artifact-error-summary" title="${esc(aerr)}">${esc(aerr.length>76?aerr.slice(0,75)+'…':aerr)}</small>`:`<small>${t.artifact_origin==='admin_drop'?`ADMIN DROP${(t.artifact_rarity||t.artifact_rarity_override)?' · '+esc(t.artifact_rarity||t.artifact_rarity_override):''}`:t.retire_after_completion?'AUSLAUFEND':t.parent_task_id?`${esc(String(t.parent_task_id).slice(-7))}`:'ROOT'}</small>`}<small class="artifactLinks">${t.artifact_uri?`<span data-artifact-task="${esc(t.id)}">Bild</span> · <span data-manifest-task="${esc(t.id)}">Manifest</span>`:''}</small></span></button>`}).join(''):'<div class="empty">Keine Tasks</div>';document.querySelectorAll('#tasks button[data-id]').forEach(b=>b.onclick=e=>{const art=e.target.closest('[data-artifact-task]'),man=e.target.closest('[data-manifest-task]');if(art){e.preventDefault();e.stopPropagation();openAdminFile(art.dataset.artifactTask,'artifact');return}if(man){e.preventDefault();e.stopPropagation();openAdminFile(man.dataset.manifestTask,'manifest');return}openTask(tasks.find(t=>t.id===b.dataset.id))})}
function renderRuntime(){
$('settingfields').innerHTML=`<div class="control-section"><div class="section-title">GLOBAL RUNTIME</div>${runtimeKeys.map(k=>`<label><span>${esc(labels[k])}</span><input type="number" step="any" data-setting="${k}" value="${settings?.[k]??''}"></label>`).join('')}<p class="small">Die Tipp-Lotterie gilt <b>getrennt pro aktivem Task</b>. Bei einem Wert &gt; 0 werden alle gültigen Tipps eines Zeitfensters gesammelt und am Fensterende exakt bis zur eingestellten Menge zufällig gezogen. Nicht gezogene Tipps werden nicht gegen das Ziel geprüft und verändern den Score nicht; ihre Sequenz wird trotzdem verbraucht. <b>0 = Lotterie aus</b>. Änderungen greifen für neu beginnende Fenster.</p><p class="small"><b>Beacon Hunt:</b> Optional wählen Spieler PULSE, FLUX oder ORBIT. Der erste öffentliche drand-Round nach Fensterschluss bestimmt reproduzierbar den Boost-Pfad und die gewichtete Ziehung. Alle Reveal-Daten werden gespeichert und über die Public API nachvollziehbar gemacht.</p><p class="small"><b>Anti-Sybil:</b> Neue Browser-Identitäten lösen einmalig einen Proof-of-Work und warten anschließend die konfigurierte Warmup-Zeit, bevor Tipps gewertet werden. Das erhöht die Kosten massenhafter Identitätserstellung, ersetzt aber keine externe echte Identitätsprüfung.</p><p class="small"><b>OpenAI Circuit Breaker:</b> Vor jedem Bild-Call werden rollierende 1h-/24h-Call-Limits und das geschätzte 24h-Kostenbudget geprüft. Bei Überschreitung bleibt die Gewinnerkarte in der Queue und wird später erneut versucht.</p><p class="small">Die übrigen Defaults gelten global. Task-spezifische Intervalle und Zahlenräume steuerst du im Tab „Task Actions“.</p></div>
<div class="control-section profile-cleanup"><div class="section-title">ALTE PROFILE BEREINIGEN</div><p class="small">Löscht ausschließlich Accounts, die <b>seit mindestens X Zeit inaktiv</b>, aktuell <b>nicht verbunden</b> und <b>niemals Gewinner</b> eines Tasks waren. Gewinner werden unabhängig vom Alter immer geschützt. Zugehörige Punkte, Unlocks und Task-Auswahl werden mit dem Profil entfernt.</p>
$('settingfields').innerHTML=`<div class="control-section"><div class="section-title">GLOBAL RUNTIME</div>${runtimeKeys.map(k=>`<label><span>${esc(labels[k])}</span><input type="number" step="any" data-setting="${k}" value="${settings?.[k]??''}"></label>`).join('')}<p class="small">Die Tipp-Lotterie gilt <b>getrennt pro aktivem Task</b>. Bei einem Wert &gt; 0 werden alle gültigen Tipps eines Zeitfensters gesammelt und am Fensterende exakt bis zur eingestellten Menge zufällig gezogen. Nicht gezogene Tipps werden nicht gegen das Ziel geprüft und verändern den Score nicht; ihre Sequenz wird trotzdem verbraucht. <b>0 = Lotterie aus</b>. Änderungen greifen für neu beginnende Fenster.</p><p class="small"><b>Beacon Hunt:</b> Optional wählen Spieler PULSE, FLUX oder ORBIT. Der erste öffentliche drand-Round nach Fensterschluss bestimmt reproduzierbar den Boost-Pfad und die gewichtete Ziehung. Alle Reveal-Daten werden gespeichert und über die Public API nachvollziehbar gemacht.</p><p class="small"><b>Anti-Sybil:</b> Neue Browser-Identitäten lösen einmalig einen Proof-of-Work und warten anschließend die konfigurierte Warmup-Zeit, bevor Tipps gewertet werden. Das erhöht die Kosten massenhafter Identitätserstellung, ersetzt aber keine externe echte Identitätsprüfung.</p><p class="small"><b>OpenAI Circuit Breaker:</b> Vor jedem Bild-Call werden rollierende 1h-/24h-Call-Limits und das geschätzte 24h-Kostenbudget geprüft. Bei Überschreitung bleibt die Gewinnerkarte in der Queue und wird später erneut versucht.</p><p class="small"><b>Neural Place:</b> Bei aktiviertem Place-Modus ist der Score ein Prozentwert von 0100. Jede echte Highscore-Steigerung erzeugt <code>Place-Punkte je +1,00 Score</code>; verknüpfte Hosted Worker schreiben dieselben Punkte dem Owner-Wallet gut. Ein Placement auch das Übermalen eines belegten Feldes kostet <code>Place-Kosten je Pixel</code>. Breite/Höhe können erweitert oder nur so weit verkleinert werden, dass kein vorhandener Pixel abgeschnitten wird; offene Place-Seiten sollten nach einer Größenänderung neu geladen werden.</p><p class="small">Die übrigen Defaults gelten global. Task-spezifische Intervalle und Zahlenräume steuerst du im Tab „Task Actions“.</p></div>
<div class="control-section profile-cleanup"><div class="section-title">ALTE PROFILE BEREINIGEN</div><p class="small">Löscht ausschließlich Accounts, die <b>seit mindestens X Zeit inaktiv</b>, aktuell <b>nicht verbunden</b>, <b>niemals Gewinner</b> eines Tasks und <b>keine Neural-Place-Teilnehmer</b> sind. Gewinner, verknüpfte Worker/Owner und Identitäten mit verdientem Place-Wert oder Placements werden unabhängig vom Alter geschützt. Zugehörige Hunt-Punkte, Unlocks und Task-Auswahl werden bei löschbaren Profilen mit entfernt.</p>
<div class="cleanup-controls"><label><span>Inaktiv seit mindestens</span><input id="profileCleanupValue" data-draft="profileCleanupValue" type="number" min="1" step="1" value="30"></label><label><span>Einheit</span><select id="profileCleanupUnit" data-draft="profileCleanupUnit"><option value="hours">Stunden</option><option value="days" selected>Tage</option><option value="weeks">Wochen</option></select></label></div>
<div class="cleanup-actions"><button id="previewProfileCleanup">PRÜFEN</button><button id="runProfileCleanup" class="danger-button">PROFILE LÖSCHEN</button></div><div id="profileCleanupResult" class="cleanup-result">Noch nicht geprüft.</div>
</div>`;$('savesettings').style.display='inline-block';$('ensuretasks').style.display='inline-block';
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=`<b>${eligible.toLocaleString('de-DE')} löschbar</b><span>· ${wins.toLocaleString('de-DE')} alte Gewinner geschützt · ${online.toLocaleString('de-DE')} aktuell verbundene Accounts geschützt</span><small>Grenze: letzte Aktivität vor ${esc(cutoff)}</small>`;};
const showCleanup=p=>{const box=$('profileCleanupResult');if(!box)return;const eligible=Number(p?.eligible||0),wins=Number(p?.protected_winners||0),place=Number(p?.protected_place||0),online=Number(p?.protected_connected||0),cutoff=p?.cutoff_ms?fmtDate(p.cutoff_ms):'—';box.innerHTML=`<b>${eligible.toLocaleString('de-DE')} löschbar</b><span>· ${wins.toLocaleString('de-DE')} alte Gewinner · ${place.toLocaleString('de-DE')} Place-Teilnehmer · ${online.toLocaleString('de-DE')} aktuell verbundene Accounts geschützt</span><small>Grenze: letzte Aktivität vor ${esc(cutoff)}</small>`;};
const previewCleanup=async()=>{try{captureDraft();const seconds=cleanupSeconds(),p=await api(`/api/admin/profiles/cleanup-preview?inactive_for_seconds=${seconds}`,{},true);showCleanup(p);return p}catch(e){msg(e.message);throw e}};
$('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=`<pre style="padding:2rem;color:#ff9bad">${esc(e.stack||e.message||e)}</pre>`});
(path.startsWith('/admin')?runAdmin():path.startsWith('/leaderboard')?runLeaderboard():path.startsWith('/place')?runPlace():runUser()).catch(e=>{app.innerHTML=`<pre style="padding:2rem;color:#ff9bad">${esc(e.stack||e.message||e)}</pre>`});

View File

@@ -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)}