RC-1
release-tag / release-image (push) Failing after 1m18s

This commit is contained in:
2026-08-10 05:48:55 +02:00
parent 4f0ac14c49
commit 005fd6ca51
52 changed files with 9020 additions and 1 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

+116
View File
@@ -0,0 +1,116 @@
package artifact
import (
"crypto/sha256"
"encoding/base64"
"fmt"
"html"
"strings"
"unicode/utf8"
)
func imageMIME(ext string) string {
switch strings.ToLower(strings.TrimPrefix(ext, ".")) {
case "jpg", "jpeg":
return "image/jpeg"
case "webp":
return "image/webp"
case "svg":
return "image/svg+xml"
default:
return "image/png"
}
}
func trimLabel(s string, maxRunes int) string {
s = strings.Join(strings.Fields(strings.TrimSpace(s)), " ")
if s == "" {
return "NEURAL HUNT"
}
if utf8.RuneCountInString(s) <= maxRunes {
return s
}
r := []rune(s)
return string(r[:maxRunes-1]) + "…"
}
// renderCardSVG converts raw generated artwork into the deterministic final
// collectible card. Keeping the typography/layout outside the image model makes
// all cards line up exactly and avoids model-generated fake text/logos.
func renderCardSVG(art []byte, artExt string, x win, traits collectionTraits) []byte {
encoded := base64.StdEncoding.EncodeToString(art)
series := html.EscapeString(strings.ToUpper(trimLabel(x.DisplayName, 28)))
theme := html.EscapeString(strings.ToUpper(trimLabel(traits.ThemeName, 24)))
rarity := html.EscapeString(traits.Rarity)
completed := x.Completed.UTC().Format("2006.01.02")
mime := imageMIME(artExt)
var sparkles strings.Builder
h := sha256.Sum256([]byte(x.ID + "|card|" + x.Winner + "|" + x.Seed))
for i := 0; i < 42; i++ {
b0 := int(h[i%32])
b1 := int(h[(i*7+5)%32])
xv := 62 + (b0*37+i*97)%900
yv := 54 + (b1*29+i*83)%1420
r := 1 + (b0+i)%4
op := 12 + (b1 % 34)
fmt.Fprintf(&sparkles, `<circle cx="%d" cy="%d" r="%d" fill="white" opacity="0.%02d"/>`, xv, yv, r, op)
}
svg := fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1536" viewBox="0 0 1024 1536" role="img" aria-label="Neural Hunt collectible card">
<defs>
<clipPath id="cardClip"><rect x="26" y="26" width="972" height="1484" rx="58"/></clipPath>
<clipPath id="artClip"><rect x="42" y="42" width="940" height="1452" rx="48"/></clipPath>
<linearGradient id="metal" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#f4f7ff"/><stop offset=".18" stop-color="%s"/><stop offset=".48" stop-color="#101725"/><stop offset=".73" stop-color="%s"/><stop offset="1" stop-color="#e9f2ff"/></linearGradient>
<linearGradient id="topShade" x1="0" y1="0" x2="0" y2="1"><stop stop-color="#050811" stop-opacity=".82"/><stop offset="1" stop-color="#050811" stop-opacity="0"/></linearGradient>
<linearGradient id="bottomShade" x1="0" y1="0" x2="0" y2="1"><stop stop-color="#07101d" stop-opacity="0"/><stop offset=".34" stop-color="#07101d" stop-opacity=".58"/><stop offset="1" stop-color="#030711" stop-opacity=".96"/></linearGradient>
<linearGradient id="holo" x1="0" y1="0" x2="1" y2="1"><stop stop-color="%s" stop-opacity=".72"/><stop offset=".42" stop-color="#ffffff" stop-opacity=".22"/><stop offset=".67" stop-color="%s" stop-opacity=".64"/><stop offset="1" stop-color="#ffffff" stop-opacity=".06"/></linearGradient>
<filter id="shadow"><feDropShadow dx="0" dy="18" stdDeviation="22" flood-color="#000000" flood-opacity=".55"/></filter>
</defs>
<g filter="url(#shadow)">
<rect x="18" y="18" width="988" height="1500" rx="66" fill="url(#metal)"/>
<rect x="28" y="28" width="968" height="1480" rx="56" fill="#0b101b"/>
</g>
<g clip-path="url(#artClip)">
<image x="42" y="42" width="940" height="1452" preserveAspectRatio="xMidYMid slice" href="data:%s;base64,%s"/>
<rect x="42" y="42" width="940" height="360" fill="url(#topShade)"/>
<rect x="42" y="940" width="940" height="554" fill="url(#bottomShade)"/>
<path d="M-80 270 L760 -80 L1100 80 L180 480 Z" fill="url(#holo)" opacity=".10"/>
<path d="M120 1530 L1130 1030 L1130 1190 L330 1530 Z" fill="url(#holo)" opacity=".12"/>
<g>%s</g>
</g>
<rect x="42" y="42" width="940" height="1452" rx="48" fill="none" stroke="#ffffff" stroke-opacity=".28" stroke-width="2"/>
<rect x="49" y="49" width="926" height="1438" rx="42" fill="none" stroke="url(#holo)" stroke-opacity=".72" stroke-width="3"/>
<!-- Header -->
<g font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Arial,sans-serif" fill="#ffffff">
<text x="76" y="104" font-size="22" font-weight="800" letter-spacing="5" opacity=".76">NEURAL HUNT</text>
<line x1="76" y1="118" x2="286" y2="118" stroke="#ffc400" stroke-width="5" stroke-linecap="round" opacity=".92"/>
<text x="76" y="154" font-size="17" font-weight="700" letter-spacing="4" opacity=".76">%s</text>
<g transform="translate(770 77)">
<rect width="166" height="54" rx="27" fill="#060a12" fill-opacity=".72" stroke="url(#holo)" stroke-width="2"/>
<text x="83" y="35" text-anchor="middle" font-size="17" font-weight="900" letter-spacing="2">%s</text>
</g>
</g>
<!-- Bottom collectible information panel -->
<g font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Arial,sans-serif" fill="#ffffff">
<text x="76" y="1234" font-size="15" font-weight="800" letter-spacing="5" opacity=".62">WINNING EDITION</text>
<text x="76" y="1290" font-size="42" font-weight="900" letter-spacing=".8">%s</text>
<line x1="76" y1="1320" x2="948" y2="1320" stroke="url(#holo)" stroke-width="2" opacity=".72"/>
<g transform="translate(76 1345)">
<rect width="176" height="50" rx="25" fill="#ffffff" fill-opacity=".10" stroke="#ffffff" stroke-opacity=".18"/>
<text x="88" y="32" text-anchor="middle" font-size="15" font-weight="800" letter-spacing="2">%d-BIT HUNT</text>
</g>
<text x="76" y="1442" font-size="14" font-weight="700" letter-spacing="2" opacity=".56">%s · ORIGINAL FULL-ART SERIES</text>
<circle cx="930" cy="1437" r="11" fill="%s"/><circle cx="902" cy="1437" r="7" fill="%s"/>
</g>
</svg>`,
traits.AccentA, traits.AccentB, traits.AccentA, traits.AccentB,
mime, encoded, sparkles.String(),
series, rarity, theme, x.RangeBits, completed, traits.AccentA, traits.AccentB,
)
return []byte(svg)
}
+316
View File
@@ -0,0 +1,316 @@
package artifact
import (
"crypto/sha256"
"encoding/binary"
"fmt"
"strings"
)
const collectionPresetRaccoon = "raccoon_full_art_v1"
const characterAnchorPrompt = `Create the canonical character reference for an original collectible-art series.
CANONICAL CHARACTER — LOCK THIS IDENTITY:
Create one original anthropomorphic silver-gray raccoon named RIFT.
Permanent visual features:
- charcoal-black raccoon eye mask with a clean, recognizable shape
- creamy white muzzle, cheeks and chest
- large expressive amber-gold eyes
- small rounded ears; one tiny notch in the RIGHT ear
- short broad muzzle and a subtle confident half-smile
- oversized head with compact athletic humanoid proportions
- thick striped raccoon tail with exactly five dark rings
- soft sculpted stylized fur clumps, never photographic fur
- friendly, clever, confident personality
WARDROBE FOR THE ANCHOR ONLY:
A simple charcoal utility overshirt with no logos, no writing and no theme-specific accessories. Keep the outfit neutral so future images can replace it completely.
COMPOSITION:
Vertical 3/4 character portrait, full torso and most of the tail visible, neutral elegant studio background, clean readable silhouette, centered, no props obscuring the face or tail.
STYLE:
Neutral high-end stylized 3D character illustration, deliberately non-photorealistic, premium animated-feature quality, rounded shapes, rich material detail, cinematic soft key light plus rim light, subtle depth of field, controlled color, polished collectible character reference. Keep the rendering neutral enough that future task-specific style references can deliberately restyle RIFT without changing his identity.
STRICT:
Exactly one raccoon character. No human face. No tiger features. No dreadlocks. No text, numbers, logo, trademark, watermark, card border, UI, duplicate character, photorealistic wildlife or realistic human anatomy.`
type themeProfile struct {
Name string
Outfit string
Accessories string
Scene string
Mood string
}
var collectionThemes = []themeProfile{
{
Name: "Neon Courier",
Outfit: "oversized midnight technical bomber, black utility vest with reflective piping, loose futuristic cargo trousers, high-top sneakers and fingerless gloves",
Accessories: "compact cross-body courier satchel and a translucent visor pushed above the eyes",
Scene: "rain-slick future city alley, glowing shop fronts, cables overhead, thin steam and floating light particles",
Mood: "energetic, adventurous and cool; electric night light with warm facial highlights",
},
{
Name: "Sakura Ronin",
Outfit: "layered modern ronin jacket, asymmetrical sash, armored fabric panels, tapered trousers and split-toe boots",
Accessories: "ornamental sheathed short sword worn as a costume prop, braided cord and a small lacquer charm",
Scene: "windy hill path beneath flowering cherry trees, distant lanterns and drifting petals at blue hour",
Mood: "calm, disciplined and cinematic with warm lantern rim light",
},
{
Name: "Lunar Botanist",
Outfit: "cream exploration suit with padded joints, soft graphite harness, compact boots and botanical specimen pockets",
Accessories: "clear bubble helmet carried under one arm and a glowing seed capsule",
Scene: "moon greenhouse with alien plants, curved windows, pale regolith outside and tiny floating pollen motes",
Mood: "curious, serene and optimistic with silver-blue light and soft green bioluminescence",
},
{
Name: "Apex Racer",
Outfit: "sleek original motorsport suit with angular panels, quilted shoulders, racing gloves and lightweight boots; absolutely no real-world team branding",
Accessories: "custom unbranded racing helmet tucked at the hip and a small timing wrist display",
Scene: "futuristic pit lane after sunset, abstract light streaks, glossy track surface and atmospheric haze",
Mood: "focused, fast and triumphant with hard edge lighting and motion energy",
},
{
Name: "Sky Captain",
Outfit: "tailored deep-green flight coat, brass-toned fasteners, layered leather-like harness, high boots and a long scarf",
Accessories: "round aviator goggles on the forehead, miniature compass and folded flight chart",
Scene: "fantasy airship observation deck above glowing clouds, propeller silhouettes and warm sunrise",
Mood: "adventurous, noble and windswept with golden backlight",
},
{
Name: "Deep Salvager",
Outfit: "stylized pressure-diver jacket, segmented shoulder guards, flexible utility trousers and heavy magnetic boots",
Accessories: "compact breathing collar, antique-looking lamp and a small salvage pouch",
Scene: "underwater ruin with rays of blue light, bubbles, coral growth, old machinery and drifting particles",
Mood: "mysterious, brave and exploratory with teal caustics and warm lamp glow",
},
{
Name: "Rooftop Skater",
Outfit: "oversized graphic-free hoodie under a cropped windbreaker, wide technical pants, knee pads and chunky skate shoes",
Accessories: "original blank skateboard with abstract geometric deck art and wireless headphones around the neck",
Scene: "sunset rooftop skate spot, concrete shapes, distant skyline, spray mist and long shadows",
Mood: "playful, rebellious and relaxed with saturated sunset gradients",
},
{
Name: "Arcane Archivist",
Outfit: "layered indigo scholar robe, structured vest, embroidered star-map patterns without readable glyphs, soft boots and fingerless reading gloves",
Accessories: "floating crystal lens, chained key and a closed oversized book with no text on the cover",
Scene: "towering fantasy archive, suspended shelves, luminous dust and circular windows showing a starry sky",
Mood: "wise, curious and magical with violet-gold volumetric light",
},
{
Name: "Desert Nomad",
Outfit: "sand-colored hooded field jacket, layered scarf, reinforced trousers, wrapped boots and lightweight shoulder guards",
Accessories: "goggles hanging at the collar, water canteen and a compact navigation device",
Scene: "vast stylized dunes around a half-buried futuristic relay station, windblown dust and distant heat shimmer",
Mood: "resilient, solitary and heroic with warm amber sunlight and cool shadow fill",
},
{
Name: "Harbor Corsair",
Outfit: "navy captain coat with exaggerated collar, striped sash, fitted waistcoat, weathered trousers and tall deck boots",
Accessories: "ornamental brass spyglass, rope bracelet and a broad captain hat with no insignia",
Scene: "fantasy harbor at dawn, stylized sailing vessel, gull silhouettes, rolling mist and bright spray",
Mood: "mischievous, bold and charismatic with crisp sea light",
},
{
Name: "Alpine Rider",
Outfit: "bright technical snow jacket, insulated bib trousers, soft neck gaiter, mittens and oversized snowboard boots",
Accessories: "reflective snow goggles lifted above the eyes and a custom blank snowboard with geometric patterning",
Scene: "high alpine ridge after fresh snowfall, powder plume, distant peaks and sparkling ice crystals",
Mood: "joyful, fearless and fresh with clean winter sunlight and blue shadows",
},
{
Name: "Noir Detective",
Outfit: "charcoal trench coat over a fitted waistcoat and shirt, narrow tie, tailored trousers and polished ankle boots",
Accessories: "soft-brim hat, small notebook with blank pages and an old-fashioned camera",
Scene: "stylized rainy midnight street, glowing windows, puddle reflections, fog and dramatic architectural shadows",
Mood: "clever, suspicious and composed with high-contrast noir lighting plus a single warm practical light",
},
{
Name: "Jungle Soundmaster",
Outfit: "bold woven-pattern jacket, relaxed dark trousers, colorful knit cap and layered fabric wristbands; original patterns only",
Accessories: "large studio headphones, portable sampler with abstract unlabelled controls and bead accents",
Scene: "lush tropical night stage tucked into giant leaves, warm string lights, mist and floating firefly-like particles",
Mood: "musical, warm and charismatic with saturated green, gold and red accents",
},
{
Name: "Solar Guardian",
Outfit: "ceremonial cream-and-gold mantle over fitted plated fabric armor, broad belt, tapered trousers and soft armored boots",
Accessories: "floating sun-disc ornament, geometric shoulder charms and a staff-shaped decorative prop",
Scene: "monumental desert temple terrace, shafts of sunlight, drifting sand and abstract solar motifs",
Mood: "regal, radiant and protective with intense gold rim light and deep cyan shadows",
},
{
Name: "Retro Astronaut",
Outfit: "chunky retro-future space suit with rounded padding, orange utility tabs, ribbed joints and oversized moon boots",
Accessories: "dome helmet carried at the side, analog-style wrist module with no readable numbers and tether clips",
Scene: "colorful retro orbital station window, star field, distant planet and floating equipment shapes",
Mood: "wonder-filled, optimistic and iconic with soft cosmic blues and warm cabin light",
},
{
Name: "Storm Mechanic",
Outfit: "rolled-sleeve heavy work jacket, dark overalls, reinforced gloves, tool belt and rugged lace-up boots",
Accessories: "large wrench-shaped prop, protective goggles on the head and a compact diagnostic tablet with abstract graphics",
Scene: "open-air repair platform during an approaching electrical storm, turbine parts, sparks and rain mist",
Mood: "resourceful, gritty and confident with cool storm light and warm sparks",
},
}
var poseOptions = []string{
"standing in a relaxed three-quarter stance with one shoulder slightly forward",
"mid-step with a confident turn toward camera",
"leaning casually against a thematic prop while keeping the full facial mask visible",
"slightly crouched in an energetic ready pose with the tail creating a strong S-curve",
"standing upright with one hand adjusting an accessory and the other relaxed",
"heroic low-angle three-quarter pose, expressive but not aggressive",
}
var atmosphereOptions = []string{
"subtle floating particles and soft volumetric haze",
"small foreground elements crossing the lower frame for depth",
"gentle wind motion in fabric and fur",
"bright rim-light bloom plus restrained lens glow",
"layered foreground bokeh and crisp facial focus",
"tiny environmental sparks or motes that echo the outfit materials",
}
type collectionTraits struct {
ThemeName string `json:"theme"`
Outfit string `json:"outfit"`
Accessories string `json:"accessories"`
Scene string `json:"scene"`
Mood string `json:"mood"`
Pose string `json:"pose"`
Atmosphere string `json:"atmosphere"`
Rarity string `json:"rarity"`
AccentA string `json:"accent_a"`
AccentB string `json:"accent_b"`
EditionCode string `json:"edition_code"`
}
var accentPairs = [][2]string{
{"#79F7FF", "#7D6CFF"},
{"#FFB84D", "#FF5D8F"},
{"#9BFF9E", "#16D7C5"},
{"#F8E16C", "#D979FF"},
{"#6AE4FF", "#FF7AD9"},
{"#FFD36A", "#53A8FF"},
{"#B7FF6A", "#FF8A5B"},
{"#C7B2FF", "#6CFFD7"},
}
func deriveCollectionTraits(x win) collectionTraits {
h := sha256.Sum256([]byte(x.ID + "|" + x.Winner + "|" + x.Seed + "|" + x.Guess))
theme := collectionThemes[int(h[0])%len(collectionThemes)]
pair := accentPairs[int(h[1])%len(accentPairs)]
rarityRoll := binary.BigEndian.Uint16(h[2:4]) % 10000
rarity := "RARE"
switch {
case rarityRoll < 35:
rarity = "MYTHIC"
case rarityRoll < 260:
rarity = "LEGENDARY"
case rarityRoll < 1250:
rarity = "EPIC"
case rarityRoll < 4200:
rarity = "RARE"
default:
rarity = "SIGNATURE"
}
return collectionTraits{
ThemeName: theme.Name,
Outfit: theme.Outfit,
Accessories: theme.Accessories,
Scene: theme.Scene,
Mood: theme.Mood,
Pose: poseOptions[int(h[4])%len(poseOptions)],
Atmosphere: atmosphereOptions[int(h[5])%len(atmosphereOptions)],
Rarity: rarity,
AccentA: pair[0],
AccentB: pair[1],
EditionCode: strings.ToUpper(fmt.Sprintf("NH-%02X%02X-%02X%02X", h[6], h[7], h[8], h[9])),
}
}
func buildCollectionPrompt(x win, traits collectionTraits) string {
taskDirection := strings.TrimSpace(x.PromptInstructions)
if taskDirection == "" {
taskDirection = "No additional task-specific direction."
}
avoid := strings.TrimSpace(x.NegativePrompt)
if avoid == "" {
avoid = "No additional task-specific avoid list."
}
return fmt.Sprintf(`Create one original premium full-art collectible character illustration.
REFERENCE IMAGES — SEPARATE IDENTITY FROM STYLE:
Image 1 is the canonical RIFT CHARACTER reference. Use Image 1 only to preserve identity: silver-gray raccoon species, charcoal eye-mask shape, creamy muzzle/chest, amber-gold eyes, right-ear notch, muzzle proportions, head-to-body proportions, five-ring tail and overall face identity. Do NOT treat Image 1's neutral wardrobe, background, lighting or rendering style as fixed.
Image 2 is the TASK STYLE reference. Use Image 2 only for visual language: rendering technique, shape language, material treatment, color behavior, lighting character, texture/detail level and overall illustration mood. Do NOT copy Image 2's subject, species, pose, clothing, cultural symbols, logos, text or composition literally.
Priority rule: identity comes from Image 1; visual style comes from Image 2; wardrobe/theme/scene come from this prompt. If the references conflict, preserve RIFT's identity first and adopt the style reference second.
SUBJECT:
RIFT, one anthropomorphic raccoon character. The character must remain unmistakably animal-like and deliberately stylized rather than realistic or human-faced.
THEME:
%s
CLOTHING — PRIMARY VARIABLE:
%s.
The clothing is the main collectible trait. It must instantly communicate the theme, look custom-tailored to RIFT, use premium materials and retain a clear silhouette. No logos or recognizable real-world brand marks.
ACCESSORIES:
%s.
SCENE:
%s.
POSE:
%s.
MOOD / LIGHTING:
%s.
ATMOSPHERE:
%s.
COMPOSITION:
Vertical 1024x1536 full-art composition. Exactly one dominant character occupying about 6575%% of the frame. Three-quarter body view with the face and right-ear notch clearly visible and at least most of the striped tail visible. Put the eyes near the upper third. Use foreground/background layering and dynamic depth, but keep the silhouette immediately readable. Leave natural darker breathing room near the very top and bottom so a programmatic collectible-card overlay can remain legible.
VISUAL LANGUAGE — TASK STYLE HAS AUTHORITY:
Follow Image 2 decisively for the rendering language. Recreate its level of stylization, line/edge treatment, brushwork or surface treatment, material abstraction, shading model, texture density, palette behavior, lighting language and finish. The Task Style may be 3D-cartoon, cel-shaded, graphic comic, painterly, watercolor-like, clay/toy-like, low-poly, retro-game-inspired or another deliberately illustrated collectible-art language. Do NOT force RIFT back into the neutral 3D look of Image 1. Preserve the permanent identity markers from Image 1 while translating them naturally into Image 2's visual language. Keep the result premium, coherent, richly art-directed and clearly non-photorealistic.
FULL-ART ENERGY:
The environment should visually merge with the character through thematic particles, light, fabric motion, mist, sparks, petals, snow, dust or other scene-appropriate elements. Create many small visual discoveries while keeping RIFT instantly readable.
TASK-SPECIFIC CREATIVE DIRECTION:
%s
TASK-SPECIFIC AVOID LIST:
%s
STRICT CONSTRAINTS:
Original character design only. Exactly one raccoon. No human face. No photorealistic wildlife. No card frame or UI inside the generated artwork. No readable words, letters, numbers, signatures, logos, trademarks or watermarks. No duplicate character. No extra tails. Tail must retain exactly five dark rings. Do not copy the style-reference subject or its clothing. Do not add random costume elements unrelated to the theme.
Deterministic creative fingerprint only, never render it as text: task=%s winner=%s difficulty=%d-bit seed=%s.`,
traits.ThemeName,
traits.Outfit,
traits.Accessories,
traits.Scene,
traits.Pose,
traits.Mood,
traits.Atmosphere,
taskDirection,
avoid,
shortHash(x.ID), shortHash(x.Winner), x.RangeBits, shortHash(x.Seed),
)
}
func collectionNegativePrompt(x win) string {
return joinPrompt(
"photorealistic, realistic wildlife, human face, human skin, duplicate character, two characters, extra tail, wrong tail rings, unreadable anatomy, malformed paws, text, letters, numbers, logo, trademark, watermark, signature, card border, UI, low detail, blur",
x.NegativePrompt,
)
}
+236
View File
@@ -0,0 +1,236 @@
package artifact
import (
"context"
"encoding/base64"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"neuralhunt/internal/settings"
)
func sampleWin() win {
return win{
ID: "task_1234567890abcdef",
Seed: "public-seed-42",
Winner: "client_abcdef1234567890",
Signature: "signature",
Guess: "777",
DisplayName: "Aurora Vault",
RangeBits: 28,
Completed: time.Date(2026, 8, 10, 12, 0, 0, 0, time.UTC),
PromptInstructions: "Add a subtle aurora motif to the environmental lighting.",
NegativePrompt: "no giant hat",
}
}
func TestCollectionTraitsDeterministic(t *testing.T) {
x := sampleWin()
a := deriveCollectionTraits(x)
b := deriveCollectionTraits(x)
if a != b {
t.Fatalf("traits are not deterministic: %#v != %#v", a, b)
}
if a.ThemeName == "" || a.Outfit == "" || a.EditionCode == "" || a.AccentA == "" {
t.Fatalf("missing dynamic traits: %#v", a)
}
}
func TestBuildCollectionPromptUsesIdentityLockAndVariables(t *testing.T) {
x := sampleWin()
traits := deriveCollectionTraits(x)
p := buildCollectionPrompt(x, traits)
for _, want := range []string{
"Image 1 is the canonical RIFT CHARACTER reference",
"Image 2 is the TASK STYLE reference",
"identity comes from Image 1; visual style comes from Image 2",
"VISUAL LANGUAGE — TASK STYLE HAS AUTHORITY",
"Do NOT force RIFT back into the neutral 3D look of Image 1",
traits.ThemeName,
traits.Outfit,
x.PromptInstructions,
x.NegativePrompt,
"exactly five dark rings",
"No card frame or UI inside the generated artwork",
} {
if !strings.Contains(p, want) {
t.Fatalf("prompt missing %q", want)
}
}
}
func TestRenderCardSVG(t *testing.T) {
x := sampleWin()
traits := deriveCollectionTraits(x)
art := []byte("fake-png-bytes")
card := string(renderCardSVG(art, "png", x, traits))
for _, want := range []string{
`width="1024" height="1536"`,
"NEURAL HUNT",
strings.ToUpper(traits.ThemeName),
"WINNING EDITION",
base64.StdEncoding.EncodeToString(art),
"data:image/png;base64,",
} {
if !strings.Contains(card, want) {
t.Fatalf("card missing %q", want)
}
}
}
func TestGPTImage2PortraitSize(t *testing.T) {
if err := validateOpenAIImageSize("gpt-image-2", 1024, 1536); err != nil {
t.Fatalf("1024x1536 should be valid: %v", err)
}
if err := validateOpenAIImageSize("gpt-image-2", 1000, 1536); err == nil {
t.Fatal("non-multiple-of-16 width should be rejected")
}
}
func TestRIFTDefaultQualityIsMedium(t *testing.T) {
t.Setenv("ARTIFACT_QUALITY", "")
if got := settings.Defaults().ArtifactQuality; got != "medium" {
t.Fatalf("default artifact quality = %q, want medium", got)
}
}
func TestOpenAIRequestSendsReferenceAsImageEdit(t *testing.T) {
var sawImage bool
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/images/edits" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
if got := r.Header.Get("Authorization"); got != "Bearer test-key" {
t.Errorf("bad auth: %q", got)
}
if err := r.ParseMultipartForm(2 << 20); err != nil {
t.Fatalf("parse multipart: %v", err)
}
if r.FormValue("model") != "gpt-image-2" || r.FormValue("size") != "1024x1536" || r.FormValue("quality") != "medium" {
t.Errorf("unexpected fields: model=%q size=%q quality=%q", r.FormValue("model"), r.FormValue("size"), r.FormValue("quality"))
}
files := r.MultipartForm.File["image[]"]
if len(files) != 1 {
t.Fatalf("expected one reference image, got %d", len(files))
}
f, err := files[0].Open()
if err != nil {
t.Fatal(err)
}
b, _ := io.ReadAll(f)
_ = f.Close()
sawImage = string(b) == "reference-bytes"
w.Header().Set("Content-Type", "application/json")
w.Header().Set("x-request-id", "req_test")
fmt.Fprintf(w, `{"data":[{"b64_json":%q}],"usage":{"total_tokens":6000,"input_tokens":3000,"output_tokens":3000,"input_tokens_details":{"text_tokens":1000,"image_tokens":2000}}}`, base64.StdEncoding.EncodeToString([]byte("generated-png")))
}))
defer ts.Close()
t.Setenv("OPENAI_API_KEY", "test-key")
t.Setenv("OPENAI_BASE_URL", ts.URL)
w := &Worker{http: ts.Client()}
cfg := settings.Defaults()
cfg.ArtifactModel = "gpt-image-2"
cfg.ArtifactWidth = 1024
cfg.ArtifactHeight = 1536
cfg.ArtifactQuality = "medium"
res, err := w.openAIRequest(context.Background(), cfg, "test prompt", []referenceImage{{Name: "reference.png", Bytes: []byte("reference-bytes")}})
if err != nil {
t.Fatal(err)
}
if !sawImage {
t.Fatal("reference image was not transmitted")
}
if string(res.Bytes) != "generated-png" || res.Meta["endpoint"] != "images/edits" {
t.Fatalf("unexpected response: %#v", res)
}
if res.Usage == nil || res.Usage.InputTokens != 3000 || res.Usage.TextInputTokens != 1000 || res.Usage.ImageInputTokens != 2000 || res.Usage.OutputTokens != 3000 {
t.Fatalf("unexpected usage: %#v", res.Usage)
}
if res.EstimatedCostUSD == nil || *res.EstimatedCostUSD < 0.110999 || *res.EstimatedCostUSD > 0.111001 {
t.Fatalf("unexpected cost estimate: %#v", res.EstimatedCostUSD)
}
}
func TestEstimateOpenAIImageCost(t *testing.T) {
u := imageUsage{InputTokens: 3000, TextInputTokens: 1000, ImageInputTokens: 2000, OutputTokens: 3000, TotalTokens: 6000}
cost, basis, ok := estimateOpenAIImageCost("gpt-image-2", u)
if !ok || basis == "" {
t.Fatalf("expected priced usage, got ok=%v basis=%q", ok, basis)
}
if cost < 0.110999 || cost > 0.111001 {
t.Fatalf("unexpected gpt-image-2 cost: %.9f", cost)
}
if _, _, ok := estimateOpenAIImageCost("future-image-model", u); ok {
t.Fatal("unknown model must not get an invented price")
}
}
func TestTaskStyleReferenceDefaultsAndCustom(t *testing.T) {
dir := t.TempDir()
w := &Worker{dir: dir}
def, err := w.loadStyleReference("")
if err != nil {
t.Fatal(err)
}
if def.Custom || def.Name != "default_style_reference.jpg" || len(def.Bytes) < 1000 || def.SHA256 == "" {
t.Fatalf("unexpected default style: %#v", def)
}
if err := os.MkdirAll(filepath.Join(dir, "_styles"), 0o750); err != nil {
t.Fatal(err)
}
customBytes := append([]byte(nil), styleReferenceJPEG...)
customName := "custom.jpg"
if err := os.WriteFile(filepath.Join(dir, "_styles", customName), customBytes, 0o640); err != nil {
t.Fatal(err)
}
custom, err := w.loadStyleReference(customName)
if err != nil {
t.Fatal(err)
}
if !custom.Custom || custom.Name != customName || custom.SHA256 != def.SHA256 {
t.Fatalf("unexpected custom style: %#v", custom)
}
if _, err := w.loadStyleReference("../escape.jpg"); err == nil {
t.Fatal("path traversal style reference should be rejected")
}
}
func TestOpenAIRequestSendsCharacterAndStyleReferences(t *testing.T) {
var names []string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(4 << 20); err != nil {
t.Fatalf("parse multipart: %v", err)
}
files := r.MultipartForm.File["image[]"]
if len(files) != 2 {
t.Fatalf("expected two references, got %d", len(files))
}
for _, f := range files {
names = append(names, f.Filename)
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"data":[{"b64_json":%q}]}`, base64.StdEncoding.EncodeToString([]byte("generated")))
}))
defer ts.Close()
t.Setenv("OPENAI_API_KEY", "test-key")
t.Setenv("OPENAI_BASE_URL", ts.URL)
w := &Worker{http: ts.Client()}
cfg := settings.Defaults()
_, err := w.openAIRequest(context.Background(), cfg, "prompt", []referenceImage{
{Name: "character_anchor.png", Bytes: []byte("anchor")},
{Name: "task_style.jpg", Bytes: []byte("style")},
})
if err != nil {
t.Fatal(err)
}
if strings.Join(names, ",") != "character_anchor.png,task_style.jpg" {
t.Fatalf("unexpected reference order: %v", names)
}
}
+323
View File
@@ -0,0 +1,323 @@
package artifact
import (
"bytes"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"neuralhunt/internal/settings"
)
type referenceImage struct {
Name string
Bytes []byte
}
var collectionAnchorMu sync.Mutex
var ErrCharacterAnchorExists = errors.New("character anchor already exists")
func validateOpenAIImageSize(model string, width, height int) error {
if !strings.EqualFold(strings.TrimSpace(model), "gpt-image-2") {
return nil
}
pixels := width * height
longEdge, shortEdge := width, height
if height > width {
longEdge, shortEdge = height, width
}
if width%16 != 0 || height%16 != 0 || longEdge > 3840 || shortEdge <= 0 || longEdge > 3*shortEdge || pixels < 655360 || pixels > 8294400 {
return fmt.Errorf("gpt-image-2 size %dx%d is invalid: edges must be multiples of 16, max edge 3840, ratio <=3:1, pixels 655360..8294400", width, height)
}
return nil
}
func (w *Worker) characterAnchorPath() string {
return filepath.Join(w.dir, "_collection", "character_anchor.png")
}
func (w *Worker) createCharacterAnchorLocked(ctx context.Context, cfg settings.Runtime, failIfExists bool) ([]byte, bool, error) {
anchorPath := w.characterAnchorPath()
if b, err := os.ReadFile(anchorPath); err == nil && len(b) > 1024 {
if failIfExists {
return nil, false, ErrCharacterAnchorExists
}
return b, false, nil
}
if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(cfg.ArtifactModel)), "gpt-image-") {
return nil, false, fmt.Errorf("collection character consistency requires a GPT Image model; got %q", cfg.ArtifactModel)
}
if err := validateOpenAIImageSize(cfg.ArtifactModel, cfg.ArtifactWidth, cfg.ArtifactHeight); err != nil {
return nil, false, err
}
if err := os.MkdirAll(filepath.Dir(anchorPath), 0o750); err != nil {
return nil, false, err
}
// The global anchor is intentionally generated without a style image. It is
// an identity reference only; visual style is supplied independently per
// task as Image 2 during actual card generation.
res, err := w.openAIRequest(ctx, cfg, characterAnchorPrompt, nil)
if err != nil {
return nil, false, fmt.Errorf("create canonical RIFT anchor: %w", err)
}
if err := w.recordOpenAIUsage(ctx, "", "character_anchor", res); err != nil {
if res.Meta == nil {
res.Meta = map[string]any{}
}
res.Meta["usage_log_error"] = err.Error()
}
if len(res.Bytes) == 0 {
return nil, false, errors.New("create canonical RIFT anchor: OpenAI returned empty image")
}
if err := atomicWrite(anchorPath, res.Bytes, 0o640); err != nil {
return nil, false, fmt.Errorf("store canonical RIFT anchor: %w", err)
}
return res.Bytes, true, nil
}
// CreateCharacterAnchor creates the collection-wide RIFT identity reference on
// demand from the admin UI. It deliberately refuses to overwrite an existing
// anchor so the identity cannot be changed accidentally after a collection has
// started.
func (w *Worker) CreateCharacterAnchor(ctx context.Context) error {
collectionAnchorMu.Lock()
defer collectionAnchorMu.Unlock()
_, _, err := w.createCharacterAnchorLocked(ctx, w.settings.Get(), true)
return err
}
func (w *Worker) ensureCharacterAnchor(ctx context.Context, cfg settings.Runtime) ([]byte, bool, error) {
collectionAnchorMu.Lock()
defer collectionAnchorMu.Unlock()
return w.createCharacterAnchorLocked(ctx, cfg, false)
}
func (w *Worker) openAI(ctx context.Context, cfg settings.Runtime, x win, prompt string) (imageResult, error) {
if err := validateOpenAIImageSize(cfg.ArtifactModel, cfg.ArtifactWidth, cfg.ArtifactHeight); err != nil {
return imageResult{}, err
}
if !strings.EqualFold(strings.TrimSpace(cfg.ArtifactPreset), collectionPresetRaccoon) {
res, err := w.openAIRequest(ctx, cfg, prompt, nil)
if err != nil {
return imageResult{}, err
}
if err := w.recordOpenAIUsage(ctx, x.ID, "artifact", res); err != nil {
if res.Meta == nil {
res.Meta = map[string]any{}
}
res.Meta["usage_log_error"] = err.Error()
}
return res, nil
}
anchor, created, err := w.ensureCharacterAnchor(ctx, cfg)
if err != nil {
return imageResult{}, err
}
styleRef, err := w.loadStyleReference(x.StyleReference)
if err != nil {
return imageResult{}, err
}
res, err := w.openAIRequest(ctx, cfg, prompt, []referenceImage{
{Name: "character_anchor.png", Bytes: anchor},
{Name: styleRef.Name, Bytes: styleRef.Bytes},
})
if err != nil {
return imageResult{}, err
}
h := sha256.Sum256(anchor)
if res.Meta == nil {
res.Meta = map[string]any{}
}
res.Meta["character_anchor_sha256"] = hex.EncodeToString(h[:])
res.Meta["character_anchor_created"] = created
res.Meta["style_reference_sha256"] = styleRef.SHA256
res.Meta["style_reference_name"] = styleRef.Name
res.Meta["style_reference_custom"] = styleRef.Custom
res.Meta["reference_mode"] = "character-plus-task-style"
if err := w.recordOpenAIUsage(ctx, x.ID, "artifact", res); err != nil {
res.Meta["usage_log_error"] = err.Error()
}
return res, nil
}
func (w *Worker) openAIRequest(ctx context.Context, cfg settings.Runtime, prompt string, refs []referenceImage) (imageResult, error) {
key := strings.TrimSpace(os.Getenv("OPENAI_API_KEY"))
if key == "" {
return imageResult{}, errors.New("OPENAI_API_KEY is not configured")
}
base := strings.TrimRight(strings.TrimSpace(os.Getenv("OPENAI_BASE_URL")), "/")
if base == "" {
base = "https://api.openai.com"
}
size := fmt.Sprintf("%dx%d", cfg.ArtifactWidth, cfg.ArtifactHeight)
quality := strings.ToLower(strings.TrimSpace(cfg.ArtifactQuality))
if quality == "" {
quality = "medium"
}
var req *http.Request
var err error
endpoint := ""
if len(refs) > 0 {
endpoint = base + "/v1/images/edits"
if strings.HasSuffix(base, "/v1") {
endpoint = base + "/images/edits"
}
var body bytes.Buffer
mw := multipart.NewWriter(&body)
fields := map[string]string{
"model": cfg.ArtifactModel,
"prompt": prompt,
"n": "1",
"size": size,
"quality": quality,
"output_format": "png",
}
for k, v := range fields {
if err := mw.WriteField(k, v); err != nil {
return imageResult{}, err
}
}
for i, ref := range refs {
if len(ref.Bytes) == 0 {
continue
}
name := strings.TrimSpace(ref.Name)
if name == "" {
name = fmt.Sprintf("reference_%d.png", i+1)
}
part, err := mw.CreateFormFile("image[]", name)
if err != nil {
return imageResult{}, err
}
if _, err := part.Write(ref.Bytes); err != nil {
return imageResult{}, err
}
}
if err := mw.Close(); err != nil {
return imageResult{}, err
}
req, err = http.NewRequestWithContext(ctx, http.MethodPost, endpoint, &body)
if err == nil {
req.Header.Set("Content-Type", mw.FormDataContentType())
}
} else {
endpoint = base + "/v1/images/generations"
if strings.HasSuffix(base, "/v1") {
endpoint = base + "/images/generations"
}
payload := map[string]any{
"model": cfg.ArtifactModel,
"prompt": prompt,
"n": 1,
"size": size,
"quality": quality,
"output_format": "png",
}
body, marshalErr := json.Marshal(payload)
if marshalErr != nil {
return imageResult{}, marshalErr
}
req, err = http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err == nil {
req.Header.Set("Content-Type", "application/json")
}
}
if err != nil {
return imageResult{}, err
}
req.Header.Set("Authorization", "Bearer "+key)
resp, err := w.http.Do(req)
if err != nil {
return imageResult{}, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 128<<20))
if err != nil {
return imageResult{}, err
}
requestID := strings.TrimSpace(resp.Header.Get("x-request-id"))
if resp.StatusCode/100 != 2 {
if requestID != "" {
return imageResult{}, fmt.Errorf("OpenAI images API HTTP %d (request %s): %s", resp.StatusCode, requestID, truncate(string(raw), 1400))
}
return imageResult{}, fmt.Errorf("OpenAI images API HTTP %d: %s", resp.StatusCode, truncate(string(raw), 1400))
}
var out struct {
Data []struct {
B64 string `json:"b64_json"`
} `json:"data"`
Usage *struct {
TotalTokens int64 `json:"total_tokens"`
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
InputDetails struct {
TextTokens int64 `json:"text_tokens"`
ImageTokens int64 `json:"image_tokens"`
} `json:"input_tokens_details"`
} `json:"usage"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return imageResult{}, fmt.Errorf("decode OpenAI image response: %w", err)
}
if len(out.Data) == 0 || out.Data[0].B64 == "" {
return imageResult{}, errors.New("OpenAI images API returned no b64_json")
}
img, err := base64.StdEncoding.DecodeString(out.Data[0].B64)
if err != nil {
return imageResult{}, err
}
meta := map[string]any{
"model": cfg.ArtifactModel,
"size": size,
"quality": quality,
"endpoint": map[bool]string{true: "images/edits", false: "images/generations"}[len(refs) > 0],
"references": len(refs),
}
if requestID != "" {
meta["request_id"] = requestID
}
result := imageResult{Bytes: img, Ext: "png", Provider: "openai", Meta: meta}
if out.Usage != nil {
u := imageUsage{
TotalTokens: out.Usage.TotalTokens,
InputTokens: out.Usage.InputTokens,
OutputTokens: out.Usage.OutputTokens,
TextInputTokens: out.Usage.InputDetails.TextTokens,
ImageInputTokens: out.Usage.InputDetails.ImageTokens,
}
if u.available() {
result.Usage = &u
meta["usage"] = map[string]any{
"total_tokens": u.TotalTokens,
"input_tokens": u.InputTokens,
"input_text_tokens": u.TextInputTokens,
"input_image_tokens": u.ImageInputTokens,
"output_tokens": u.OutputTokens,
}
if cost, basis, ok := estimateOpenAIImageCost(cfg.ArtifactModel, u); ok {
result.EstimatedCostUSD = &cost
result.PricingBasis = basis
meta["estimated_cost_usd"] = cost
meta["pricing_basis"] = basis
} else if basis != "" {
result.PricingBasis = basis
meta["pricing_basis"] = basis
meta["estimated_cost_available"] = false
}
}
}
return result, nil
}
+21
View File
@@ -0,0 +1,21 @@
package artifact
import _ "embed"
// styleReferenceJPEG is the bundled fallback style used for tasks that do not
// have their own uploaded style reference. In the RIFT v2.8 pipeline it is no
// longer used to define the global character identity: character_anchor.png is
// generated independently, while every card receives the global character
// anchor plus a task-specific (or this default) style reference.
//
//go:embed assets/style_reference.jpg
var styleReferenceJPEG []byte
// DefaultStyleReferenceJPEG returns a copy of the bundled fallback reference so
// the authenticated/admin and public task-style preview handlers can render the
// same fallback that the image worker will use.
func DefaultStyleReferenceJPEG() []byte {
out := make([]byte, len(styleReferenceJPEG))
copy(out, styleReferenceJPEG)
return out
}
+81
View File
@@ -0,0 +1,81 @@
package artifact
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"mime"
"net/http"
"os"
"path/filepath"
"strings"
)
// TaskStyleReference is the style-only reference used as Image 2 for RIFT
// generation. Custom style assets live below <ARTIFACT_DIR>/_styles; an empty
// task reference resolves to the bundled default image.
type TaskStyleReference struct {
Bytes []byte `json:"-"`
Name string `json:"name"`
ContentType string `json:"content_type"`
Custom bool `json:"custom"`
SHA256 string `json:"sha256"`
}
func styleContentType(name string, b []byte) string {
ct := http.DetectContentType(b)
if ct == "application/octet-stream" {
if ext := filepath.Ext(name); ext != "" {
if v := mime.TypeByExtension(ext); v != "" {
ct = v
}
}
}
return strings.Split(ct, ";")[0]
}
func (w *Worker) loadStyleReference(ref string) (TaskStyleReference, error) {
ref = strings.TrimSpace(ref)
if ref == "" {
b := DefaultStyleReferenceJPEG()
h := sha256.Sum256(b)
return TaskStyleReference{
Bytes: b,
Name: "default_style_reference.jpg",
ContentType: "image/jpeg",
Custom: false,
SHA256: hex.EncodeToString(h[:]),
}, nil
}
if filepath.Base(ref) != ref || strings.ContainsAny(ref, `/\\`) {
return TaskStyleReference{}, fmt.Errorf("invalid task style reference %q", ref)
}
p := filepath.Join(w.dir, "_styles", ref)
b, err := os.ReadFile(p)
if err != nil {
return TaskStyleReference{}, fmt.Errorf("read task style reference %q: %w", ref, err)
}
if len(b) < 128 {
return TaskStyleReference{}, fmt.Errorf("task style reference %q is empty or invalid", ref)
}
h := sha256.Sum256(b)
return TaskStyleReference{
Bytes: b,
Name: ref,
ContentType: styleContentType(ref, b),
Custom: true,
SHA256: hex.EncodeToString(h[:]),
}, nil
}
// TaskStyleReference reads the configured style for a task. It is used by the
// UI preview endpoints and intentionally returns the bundled default when the
// task has no custom style assigned.
func (w *Worker) TaskStyleReference(ctx context.Context, taskID string) (TaskStyleReference, error) {
var ref string
if err := w.db.QueryRowContext(ctx, `SELECT nft_style_reference FROM tasks WHERE id=?`, taskID).Scan(&ref); err != nil {
return TaskStyleReference{}, err
}
return w.loadStyleReference(ref)
}
+76
View File
@@ -0,0 +1,76 @@
package artifact
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"html"
"os"
"path/filepath"
"time"
)
// CreatePipelineTestCard exercises the local, post-generation RIFT pipeline
// without calling any image provider. The existing character anchor is used as
// the mock subject and the selected task style image as the background.
func (w *Worker) CreatePipelineTestCard(ctx context.Context, taskID string) (string, error) {
var x win
if err := w.db.QueryRowContext(ctx, `SELECT id,public_seed,display_name,range_bits,nft_prompt_instructions,nft_negative_prompt,nft_style_reference FROM tasks WHERE id=?`, taskID).
Scan(&x.ID, &x.Seed, &x.DisplayName, &x.RangeBits, &x.PromptInstructions, &x.NegativePrompt, &x.StyleReference); err != nil {
return "", err
}
anchor, err := os.ReadFile(w.characterAnchorPath())
if err != nil || len(anchor) <= 1024 {
return "", errors.New("character_anchor.png fehlt; bitte zuerst den RIFT-Anchor erzeugen")
}
style, err := w.loadStyleReference(x.StyleReference)
if err != nil {
return "", err
}
x.Winner = "LOCAL-PIPELINE-TEST"
x.Signature = "NO-API-NO-SIGNATURE"
x.Guess = "LOCAL-TEST"
x.Completed = time.Now().UTC()
x.PublicJWK = json.RawMessage(`{}`)
traits := deriveCollectionTraits(x)
art := mockTestArtwork(anchor, style)
card := renderCardSVG(art, "svg", x, traits)
dir := filepath.Join(w.dir, "_test", taskID)
if err := os.MkdirAll(dir, 0o750); err != nil {
return "", err
}
if err := atomicWrite(filepath.Join(dir, "art.svg"), art, 0o640); err != nil {
return "", err
}
if err := atomicWrite(filepath.Join(dir, "image.svg"), card, 0o640); err != nil {
return "", err
}
manifest, _ := json.MarshalIndent(map[string]any{
"kind": "local_pipeline_test", "api_calls": 0, "estimated_cost_usd": 0,
"task_id": taskID, "style_reference": style.Name, "style_custom": style.Custom,
"collection_character": "RIFT", "collection_traits": traits,
"note": "Local test only. No OpenAI/API request was made and no task artifact status was changed.",
}, "", " ")
_ = atomicWrite(filepath.Join(dir, "manifest.json"), manifest, 0o640)
return filepath.Join(dir, "image.svg"), nil
}
func mockTestArtwork(anchor []byte, style TaskStyleReference) []byte {
a := base64.StdEncoding.EncodeToString(anchor)
s := base64.StdEncoding.EncodeToString(style.Bytes)
styleMime := html.EscapeString(style.ContentType)
if styleMime == "" {
styleMime = "image/jpeg"
}
return []byte(fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1536" viewBox="0 0 1024 1536">
<defs>
<filter id="blur"><feGaussianBlur stdDeviation="18"/></filter>
<linearGradient id="shade" x1="0" y1="0" x2="0" y2="1"><stop stop-color="#07101d" stop-opacity=".18"/><stop offset="1" stop-color="#07101d" stop-opacity=".72"/></linearGradient>
</defs>
<image href="data:%s;base64,%s" x="-70" y="-50" width="1164" height="1636" preserveAspectRatio="xMidYMid slice" filter="url(#blur)" opacity=".62"/>
<rect width="1024" height="1536" fill="url(#shade)"/>
<image href="data:image/png;base64,%s" x="92" y="120" width="840" height="1180" preserveAspectRatio="xMidYMid meet"/>
</svg>`, styleMime, s, a))
}
+147
View File
@@ -0,0 +1,147 @@
package artifact
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
)
// imageUsage mirrors the usage object returned by GPT Image endpoints. Keeping
// it typed avoids losing precision when provider metadata is persisted.
type imageUsage struct {
TotalTokens int64 `json:"total_tokens"`
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
TextInputTokens int64 `json:"text_input_tokens"`
ImageInputTokens int64 `json:"image_input_tokens"`
}
func (u imageUsage) available() bool {
return u.TotalTokens > 0 || u.InputTokens > 0 || u.OutputTokens > 0 || u.TextInputTokens > 0 || u.ImageInputTokens > 0
}
type imagePricing struct {
TextInputPerMTok float64
ImageInputPerMTok float64
ImageOutputPerMTok float64
Basis string
}
// openAIImagePricing intentionally supports only models whose public standard
// token rates are pinned here. Unknown/future model names still get full usage
// logging, but their local cost remains NULL instead of inventing a price.
func openAIImagePricing(model string) (imagePricing, bool) {
m := strings.ToLower(strings.TrimSpace(model))
switch {
case m == "gpt-image-2" || strings.HasPrefix(m, "gpt-image-2-"):
return imagePricing{
TextInputPerMTok: 5.00,
ImageInputPerMTok: 8.00,
ImageOutputPerMTok: 30.00,
Basis: "openai-standard-2026-08-10:gpt-image-2:text-in-5,image-in-8,image-out-30-usd-per-mtok",
}, true
case m == "gpt-image-1.5" || strings.HasPrefix(m, "gpt-image-1.5-"):
return imagePricing{
TextInputPerMTok: 5.00,
ImageInputPerMTok: 8.00,
ImageOutputPerMTok: 32.00,
Basis: "openai-standard-2026-08-10:gpt-image-1.5:text-in-5,image-in-8,image-out-32-usd-per-mtok",
}, true
case m == "chatgpt-image-latest":
return imagePricing{
TextInputPerMTok: 5.00,
ImageInputPerMTok: 8.00,
ImageOutputPerMTok: 32.00,
Basis: "openai-standard-2026-08-10:chatgpt-image-latest:text-in-5,image-in-8,image-out-32-usd-per-mtok",
}, true
case m == "gpt-image-1":
return imagePricing{
TextInputPerMTok: 5.00,
ImageInputPerMTok: 10.00,
ImageOutputPerMTok: 40.00,
Basis: "openai-standard-2026-08-10:gpt-image-1:text-in-5,image-in-10,image-out-40-usd-per-mtok",
}, true
case m == "gpt-image-1-mini":
return imagePricing{
TextInputPerMTok: 2.00,
ImageInputPerMTok: 2.50,
ImageOutputPerMTok: 8.00,
Basis: "openai-standard-2026-08-10:gpt-image-1-mini:text-in-2,image-in-2.5,image-out-8-usd-per-mtok",
}, true
default:
return imagePricing{}, false
}
}
func estimateOpenAIImageCost(model string, u imageUsage) (float64, string, bool) {
p, ok := openAIImagePricing(model)
if !ok || !u.available() {
return 0, "", false
}
// GPT Image reports the text/image input split. It matters because the two
// modalities have different rates. If the split is missing, store usage but
// do not claim a precise local cost.
if u.InputTokens > 0 && u.TextInputTokens+u.ImageInputTokens <= 0 {
return 0, p.Basis, false
}
if split := u.TextInputTokens + u.ImageInputTokens; u.InputTokens > 0 && split > 0 && split != u.InputTokens {
return 0, p.Basis, false
}
cost := (float64(u.TextInputTokens)*p.TextInputPerMTok +
float64(u.ImageInputTokens)*p.ImageInputPerMTok +
float64(u.OutputTokens)*p.ImageOutputPerMTok) / 1_000_000
return cost, p.Basis, true
}
func (w *Worker) recordOpenAIUsage(ctx context.Context, taskID, kind string, res imageResult) error {
// Log every successful OpenAI image call, even if a provider response omits
// the optional usage block. That keeps the call/card counters truthful while
// leaving the local cost NULL when there is not enough data to calculate it.
u := imageUsage{}
if res.Usage != nil {
u = *res.Usage
}
model := metaString(res.Meta, "model")
endpoint := metaString(res.Meta, "endpoint")
size := metaString(res.Meta, "size")
quality := metaString(res.Meta, "quality")
requestID := metaString(res.Meta, "request_id")
var cost any
if res.EstimatedCostUSD != nil {
cost = *res.EstimatedCostUSD
}
metaJSON, _ := json.Marshal(res.Meta)
_, err := w.db.ExecContext(ctx, `INSERT INTO artifact_api_usage(
created_at,task_id,kind,provider,model,endpoint,size,quality,request_id,
input_tokens,input_text_tokens,input_image_tokens,output_tokens,total_tokens,
estimated_cost_usd,pricing_basis,meta_json)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
time.Now().UTC().UnixMilli(), nullableString(taskID), kind, res.Provider, model, endpoint, size, quality, nullableString(requestID),
u.InputTokens, u.TextInputTokens, u.ImageInputTokens, u.OutputTokens, u.TotalTokens,
cost, res.PricingBasis, string(metaJSON))
return err
}
func nullableString(s string) any {
if strings.TrimSpace(s) == "" {
return nil
}
return s
}
func metaString(meta map[string]any, key string) string {
if meta == nil {
return ""
}
v, ok := meta[key]
if !ok || v == nil {
return ""
}
if s, ok := v.(string); ok {
return s
}
return fmt.Sprint(v)
}
+589
View File
@@ -0,0 +1,589 @@
package artifact
import (
"bytes"
"context"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"neuralhunt/internal/settings"
)
type Worker struct {
db *sql.DB
dir string
publicBase string
settings *settings.Manager
http *http.Client
}
func New(db *sql.DB, dir string, sm *settings.Manager) (*Worker, error) {
if dir == "" {
dir = "./data/artifacts"
}
abs, err := filepath.Abs(dir)
if err != nil {
return nil, err
}
if err := os.MkdirAll(abs, 0o750); err != nil {
return nil, err
}
return &Worker{
db: db,
dir: abs,
publicBase: strings.TrimRight(os.Getenv("ARTIFACT_PUBLIC_BASE_URL"), "/"),
settings: sm,
http: &http.Client{Timeout: envDuration("ARTIFACT_HTTP_TIMEOUT", 4*time.Minute)},
}, nil
}
func envDuration(k string, d time.Duration) time.Duration {
if raw := strings.TrimSpace(os.Getenv(k)); raw != "" {
if v, err := time.ParseDuration(raw); err == nil {
return v
}
}
return d
}
type win struct {
ID, Seed, Winner, Signature, Guess string
DisplayName string
RangeBits int
Completed time.Time
PublicJWK json.RawMessage
PromptInstructions string
NegativePrompt string
StyleReference string
}
func (w *Worker) Run(ctx context.Context) {
t := time.NewTicker(3 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
_ = w.one(ctx)
}
}
}
func (w *Worker) claim(ctx context.Context) (win, error) {
tx, err := w.db.BeginTx(ctx, nil)
if err != nil {
return win{}, err
}
defer tx.Rollback()
var x win
var completedMS int64
var raw string
err = tx.QueryRowContext(ctx, `SELECT t.id,t.public_seed,t.winner_client_id,t.winner_signature,t.winning_guess,t.display_name,t.range_bits,t.completed_at,c.public_jwk,t.nft_prompt_instructions,t.nft_negative_prompt,t.nft_style_reference
FROM tasks t JOIN clients c ON c.id=t.winner_client_id
WHERE t.artifact_status='pending' ORDER BY t.completed_at LIMIT 1`).Scan(&x.ID, &x.Seed, &x.Winner, &x.Signature, &x.Guess, &x.DisplayName, &x.RangeBits, &completedMS, &raw, &x.PromptInstructions, &x.NegativePrompt, &x.StyleReference)
if err != nil {
return win{}, err
}
x.Completed = time.UnixMilli(completedMS).UTC()
x.PublicJWK = json.RawMessage(raw)
if _, err = tx.ExecContext(ctx, `UPDATE tasks SET artifact_status='generating',artifact_error=NULL WHERE id=? AND artifact_status='pending'`, x.ID); err != nil {
return win{}, err
}
if err = tx.Commit(); err != nil {
return win{}, err
}
return x, nil
}
type imageResult struct {
Bytes []byte
Ext string
Provider string
Meta map[string]any
Usage *imageUsage
EstimatedCostUSD *float64
PricingBasis string
}
func (w *Worker) one(ctx context.Context) error {
x, err := w.claim(ctx)
if errors.Is(err, sql.ErrNoRows) {
return nil
}
if err != nil {
return err
}
cfg := w.settings.Get()
preset := strings.ToLower(strings.TrimSpace(cfg.ArtifactPreset))
traits := deriveCollectionTraits(x)
prompt := ""
negativePrompt := ""
if preset == collectionPresetRaccoon {
prompt = buildCollectionPrompt(x, traits)
negativePrompt = collectionNegativePrompt(x)
} else {
prompt = buildPrompt(cfg.ArtifactPrompt, x.PromptInstructions, x)
negativePrompt = joinPrompt(cfg.ArtifactNegativePrompt, x.NegativePrompt)
}
img, err := w.generate(ctx, cfg, x, prompt, negativePrompt)
if err != nil {
w.fail(ctx, x.ID, err)
return err
}
if len(img.Bytes) == 0 {
err := errors.New("image provider returned empty output")
w.fail(ctx, x.ID, err)
return err
}
if img.Ext == "" {
img.Ext = "png"
}
idSum := sha256.Sum256([]byte(x.ID + "|" + x.Winner + "|" + x.Signature))
artifactID := "artifact_" + hex.EncodeToString(idSum[:12])
promptSum := sha256.Sum256([]byte(prompt))
rawArtSum := sha256.Sum256(img.Bytes)
finalBytes := img.Bytes
finalExt := strings.TrimPrefix(strings.ToLower(img.Ext), ".")
if preset == collectionPresetRaccoon {
finalBytes = renderCardSVG(img.Bytes, img.Ext, x, traits)
finalExt = "svg"
}
finalSum := sha256.Sum256(finalBytes)
manifest := map[string]any{
"artifact_id": artifactID,
"artifact_preset": preset,
"task_id": x.ID,
"task_display_name": x.DisplayName,
"task_range_bits": x.RangeBits,
"winner_client_id": x.Winner,
"winner_public_jwk": json.RawMessage(x.PublicJWK),
"winning_guess": x.Guess,
"winner_guess_signature": x.Signature,
"completed_at": x.Completed,
"image_sha256": hex.EncodeToString(finalSum[:]),
"raw_art_sha256": hex.EncodeToString(rawArtSum[:]),
"prompt_sha256": hex.EncodeToString(promptSum[:]),
"task_prompt_instructions": x.PromptInstructions,
"task_style_reference": x.StyleReference,
"provider": img.Provider,
"provider_meta": img.Meta,
"note": "winner_guess_signature authenticates the winning guess; image_sha256 binds the final programmatically laid-out collectible card into the server manifest",
}
if preset == collectionPresetRaccoon {
manifest["collection_character"] = "RIFT"
manifest["collection_traits"] = traits
manifest["layout"] = map[string]any{
"format": "svg",
"width": 1024,
"height": 1536,
"mode": "programmatic-full-art-card-v1",
}
}
mb, _ := json.MarshalIndent(manifest, "", " ")
relDir := filepath.Join("artifacts", artifactID)
outDir := filepath.Join(w.dir, artifactID)
if err := os.MkdirAll(outDir, 0o750); err != nil {
w.fail(ctx, x.ID, err)
return err
}
if preset == collectionPresetRaccoon {
rawName := "art." + strings.TrimPrefix(strings.ToLower(img.Ext), ".")
if err := atomicWrite(filepath.Join(outDir, rawName), img.Bytes, 0o640); err != nil {
w.fail(ctx, x.ID, err)
return err
}
}
imageName := "image." + finalExt
if err := atomicWrite(filepath.Join(outDir, imageName), finalBytes, 0o640); err != nil {
w.fail(ctx, x.ID, err)
return err
}
if err := atomicWrite(filepath.Join(outDir, "manifest.json"), mb, 0o640); err != nil {
w.fail(ctx, x.ID, err)
return err
}
imgURI := w.uri(filepath.ToSlash(filepath.Join(relDir, imageName)))
manURI := w.uri(filepath.ToSlash(filepath.Join(relDir, "manifest.json")))
_, err = w.db.ExecContext(ctx, `UPDATE tasks SET artifact_status='ready',artifact_uri=?,artifact_manifest_uri=?,artifact_error=NULL WHERE id=?`, imgURI, manURI, x.ID)
return err
}
func joinPrompt(parts ...string) string {
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return strings.Join(out, "\n")
}
func buildPrompt(base, taskInstructions string, x win) string {
winner := x.Winner
if len(winner) > 24 {
winner = winner[:24]
}
creative := joinPrompt(base, taskInstructions)
return creative + fmt.Sprintf("\nTask fingerprint: %s. Winner fingerprint: %s. Difficulty: %d-bit probability space. Public seed fingerprint: %s. Treat these values only as deterministic creative seeds; do not render them as readable text.", x.ID, winner, x.RangeBits, shortHash(x.Seed))
}
func shortHash(s string) string {
h := sha256.Sum256([]byte(s))
return hex.EncodeToString(h[:8])
}
func (w *Worker) generate(ctx context.Context, cfg settings.Runtime, x win, prompt, negativePrompt string) (imageResult, error) {
provider := strings.ToLower(strings.TrimSpace(cfg.ArtifactProvider))
switch provider {
case "local":
return imageResult{Bytes: procedural(x), Ext: "svg", Provider: "local-procedural", Meta: map[string]any{"model": "deterministic-svg"}}, nil
case "openai":
return w.openAI(ctx, cfg, x, prompt)
case "comfyui":
return w.comfyUI(ctx, cfg, x, prompt, negativePrompt)
case "a1111":
return w.a1111(ctx, cfg, x, prompt, negativePrompt)
case "auto":
var errs []string
if strings.TrimSpace(os.Getenv("OPENAI_API_KEY")) != "" {
if r, err := w.openAI(ctx, cfg, x, prompt); err == nil {
return r, nil
} else {
errs = append(errs, "openai: "+err.Error())
}
}
if strings.TrimSpace(os.Getenv("COMFYUI_URL")) != "" && strings.TrimSpace(os.Getenv("COMFYUI_WORKFLOW_PATH")) != "" {
if r, err := w.comfyUI(ctx, cfg, x, prompt, negativePrompt); err == nil {
return r, nil
} else {
errs = append(errs, "comfyui: "+err.Error())
}
}
if strings.TrimSpace(os.Getenv("A1111_URL")) != "" {
if r, err := w.a1111(ctx, cfg, x, prompt, negativePrompt); err == nil {
return r, nil
} else {
errs = append(errs, "a1111: "+err.Error())
}
}
meta := map[string]any{"model": "deterministic-svg"}
if len(errs) > 0 {
meta["fallback_errors"] = errs
}
return imageResult{Bytes: procedural(x), Ext: "svg", Provider: "local-procedural-fallback", Meta: meta}, nil
default:
return imageResult{}, fmt.Errorf("unknown artifact provider %q", provider)
}
}
func (w *Worker) comfyUI(ctx context.Context, cfg settings.Runtime, x win, prompt, negativePrompt string) (imageResult, error) {
base := strings.TrimRight(strings.TrimSpace(os.Getenv("COMFYUI_URL")), "/")
workflowPath := strings.TrimSpace(os.Getenv("COMFYUI_WORKFLOW_PATH"))
if base == "" || workflowPath == "" {
return imageResult{}, errors.New("COMFYUI_URL and COMFYUI_WORKFLOW_PATH are required")
}
raw, err := os.ReadFile(workflowPath)
if err != nil {
return imageResult{}, fmt.Errorf("read ComfyUI workflow: %w", err)
}
var workflow any
if err := json.Unmarshal(raw, &workflow); err != nil {
return imageResult{}, fmt.Errorf("parse ComfyUI workflow: %w", err)
}
seed := deterministicSeed(x)
replacements := map[string]string{
"{{PROMPT}}": prompt,
"{{NEGATIVE_PROMPT}}": negativePrompt,
"{{SEED}}": strconv.FormatInt(seed, 10),
"{{WIDTH}}": strconv.Itoa(cfg.ArtifactWidth),
"{{HEIGHT}}": strconv.Itoa(cfg.ArtifactHeight),
"{{STEPS}}": strconv.Itoa(cfg.ArtifactSteps),
"{{MODEL}}": cfg.ArtifactModel,
}
workflow = replaceJSON(workflow, replacements)
clientID := "neuralhunt-" + shortHash(x.ID)
payload := map[string]any{"prompt": workflow, "client_id": clientID}
var queued struct {
PromptID string `json:"prompt_id"`
Error any `json:"error"`
Nodes map[string]any `json:"node_errors"`
}
if err := w.doJSON(ctx, http.MethodPost, base+"/prompt", payload, &queued, "", ""); err != nil {
return imageResult{}, err
}
if queued.PromptID == "" {
return imageResult{}, fmt.Errorf("ComfyUI rejected workflow: error=%v node_errors=%v", queued.Error, queued.Nodes)
}
deadline := time.Now().Add(envDuration("COMFYUI_POLL_TIMEOUT", 4*time.Minute))
for time.Now().Before(deadline) {
select {
case <-ctx.Done():
return imageResult{}, ctx.Err()
case <-time.After(900 * time.Millisecond):
}
resp, err := w.http.Get(base + "/history/" + url.PathEscape(queued.PromptID))
if err != nil {
continue
}
b, readErr := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
resp.Body.Close()
if readErr != nil || resp.StatusCode/100 != 2 {
continue
}
var history map[string]any
if json.Unmarshal(b, &history) != nil {
continue
}
entry, ok := history[queued.PromptID].(map[string]any)
if !ok {
continue
}
ref, ok := findComfyImage(entry["outputs"])
if !ok {
continue
}
q := url.Values{}
q.Set("filename", ref.Filename)
q.Set("subfolder", ref.Subfolder)
q.Set("type", ref.Type)
imgResp, err := w.http.Get(base + "/view?" + q.Encode())
if err != nil {
return imageResult{}, err
}
img, err := io.ReadAll(io.LimitReader(imgResp.Body, 64<<20))
imgResp.Body.Close()
if err != nil {
return imageResult{}, err
}
if imgResp.StatusCode/100 != 2 {
return imageResult{}, fmt.Errorf("ComfyUI /view HTTP %d", imgResp.StatusCode)
}
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(ref.Filename)), ".")
if ext == "" {
ext = "png"
}
return imageResult{Bytes: img, Ext: ext, Provider: "comfyui", Meta: map[string]any{"prompt_id": queued.PromptID, "model": cfg.ArtifactModel, "seed": seed}}, nil
}
return imageResult{}, errors.New("ComfyUI generation timed out")
}
type comfyImageRef struct{ Filename, Subfolder, Type string }
func findComfyImage(v any) (comfyImageRef, bool) {
switch x := v.(type) {
case map[string]any:
if fn, ok := x["filename"].(string); ok && fn != "" {
sub, _ := x["subfolder"].(string)
typ, _ := x["type"].(string)
if typ == "" {
typ = "output"
}
return comfyImageRef{fn, sub, typ}, true
}
for _, child := range x {
if r, ok := findComfyImage(child); ok {
return r, true
}
}
case []any:
for _, child := range x {
if r, ok := findComfyImage(child); ok {
return r, true
}
}
}
return comfyImageRef{}, false
}
func replaceJSON(v any, repl map[string]string) any {
switch x := v.(type) {
case map[string]any:
for k, child := range x {
x[k] = replaceJSON(child, repl)
}
return x
case []any:
for i := range x {
x[i] = replaceJSON(x[i], repl)
}
return x
case string:
original := x
for from, to := range repl {
x = strings.ReplaceAll(x, from, to)
}
// Exact numeric placeholders become JSON numbers where possible.
if original == "{{SEED}}" || original == "{{WIDTH}}" || original == "{{HEIGHT}}" || original == "{{STEPS}}" {
if n, err := strconv.ParseInt(x, 10, 64); err == nil {
return n
}
}
return x
default:
return v
}
}
func (w *Worker) a1111(ctx context.Context, cfg settings.Runtime, x win, prompt, negativePrompt string) (imageResult, error) {
base := strings.TrimRight(strings.TrimSpace(os.Getenv("A1111_URL")), "/")
if base == "" {
return imageResult{}, errors.New("A1111_URL is not configured")
}
seed := deterministicSeed(x)
payload := map[string]any{
"prompt": prompt,
"negative_prompt": negativePrompt,
"steps": cfg.ArtifactSteps,
"width": cfg.ArtifactWidth,
"height": cfg.ArtifactHeight,
"seed": seed,
"cfg_scale": envFloat("A1111_CFG_SCALE", 7.0),
}
if sampler := strings.TrimSpace(os.Getenv("A1111_SAMPLER")); sampler != "" {
payload["sampler_name"] = sampler
}
if model := strings.TrimSpace(cfg.ArtifactModel); model != "" && !strings.HasPrefix(strings.ToLower(model), "gpt-image") {
payload["override_settings"] = map[string]any{"sd_model_checkpoint": model}
}
var out struct {
Images []string `json:"images"`
Info string `json:"info"`
}
if err := w.doJSON(ctx, http.MethodPost, base+"/sdapi/v1/txt2img", payload, &out, os.Getenv("A1111_USER"), os.Getenv("A1111_PASSWORD")); err != nil {
return imageResult{}, err
}
if len(out.Images) == 0 {
return imageResult{}, errors.New("A1111 returned no images")
}
b64 := out.Images[0]
if i := strings.Index(b64, ","); strings.HasPrefix(b64, "data:") && i >= 0 {
b64 = b64[i+1:]
}
img, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
return imageResult{}, err
}
return imageResult{Bytes: img, Ext: "png", Provider: "a1111", Meta: map[string]any{"seed": seed, "steps": cfg.ArtifactSteps, "model": cfg.ArtifactModel}}, nil
}
func (w *Worker) doJSON(ctx context.Context, method, endpoint string, payload any, out any, user, pass string) error {
body, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, method, endpoint, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if strings.TrimSpace(user) != "" {
req.SetBasicAuth(user, pass)
}
resp, err := w.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
if err != nil {
return err
}
if resp.StatusCode/100 != 2 {
return fmt.Errorf("HTTP %d from %s: %s", resp.StatusCode, endpoint, truncate(string(raw), 1200))
}
if out == nil {
return nil
}
if err := json.Unmarshal(raw, out); err != nil {
return fmt.Errorf("decode %s: %w", endpoint, err)
}
return nil
}
func envFloat(k string, d float64) float64 {
if raw := strings.TrimSpace(os.Getenv(k)); raw != "" {
if v, err := strconv.ParseFloat(raw, 64); err == nil {
return v
}
}
return d
}
func deterministicSeed(x win) int64 {
h := sha256.Sum256([]byte(x.ID + "|" + x.Winner + "|" + x.Seed))
var n uint64
for i := 0; i < 8; i++ {
n = (n << 8) | uint64(h[i])
}
return int64(n & 0x7fffffffffffffff)
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}
func (w *Worker) uri(rel string) string {
p := "/" + strings.TrimLeft(rel, "/")
if w.publicBase == "" {
return p
}
return w.publicBase + p
}
func (w *Worker) fail(ctx context.Context, taskID string, err error) {
_, _ = w.db.ExecContext(ctx, `UPDATE tasks SET artifact_status='error',artifact_error=? WHERE id=?`, err.Error(), taskID)
}
func atomicWrite(path string, data []byte, mode os.FileMode) error {
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, mode); err != nil {
return err
}
_ = os.Remove(path)
return os.Rename(tmp, path)
}
func procedural(x win) []byte {
h := sha256.Sum256([]byte(x.ID + x.Winner + x.Seed))
a := int(h[0]) % 360
b := int(h[1]) % 360
c := int(h[2]) % 360
svg := fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024"><defs><radialGradient id="g"><stop stop-color="hsl(%d 100%% 70%%)"/><stop offset=".55" stop-color="hsl(%d 85%% 45%%)"/><stop offset="1" stop-color="#04111f"/></radialGradient><filter id="gl"><feGaussianBlur stdDeviation="8"/></filter></defs><rect width="1024" height="1024" fill="#04111f"/><circle cx="512" cy="512" r="390" fill="none" stroke="#6574ff" opacity=".18" stroke-width="2"/><circle cx="512" cy="512" r="290" fill="url(#g)" opacity=".22" filter="url(#gl)"/><g fill="hsl(%d 100%% 75%%)">`, a, b, c)
for i := 0; i < 480; i++ {
v := int(h[i%32])
xv := 512 + ((i*73 + v*11) % 620) - 310
yv := 512 + ((i*97 + v*7) % 620) - 310
r := 1 + (v % 5)
svg += fmt.Sprintf(`<circle cx="%d" cy="%d" r="%d" opacity=".65"/>`, xv, yv, r)
}
svg += fmt.Sprintf(`</g><text x="52" y="900" fill="#d9f7ff" font-family="monospace" font-size="28">%s</text><text x="52" y="940" fill="#7fe7ff" font-family="monospace" font-size="18">winner %s</text></svg>`, x.ID, x.Winner[:min(20, len(x.Winner))])
return []byte(svg)
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
+198
View File
@@ -0,0 +1,198 @@
package auth
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"math/big"
"strings"
"sync"
"time"
)
var b64 = base64.RawURLEncoding
type PublicJWK struct {
Kty string `json:"kty"`
Crv string `json:"crv"`
X string `json:"x"`
Y string `json:"y"`
Ext bool `json:"ext,omitempty"`
KeyOps []string `json:"key_ops,omitempty"`
// Optional standard JWK metadata. Authentication intentionally does not
// depend on these values; accepting them keeps WebCrypto exports portable
// across Chrome, Firefox, Safari and other standards-compliant browsers.
Alg string `json:"alg,omitempty"`
Use string `json:"use,omitempty"`
Kid string `json:"kid,omitempty"`
}
type Claims struct {
ClientID string `json:"cid"`
Role string `json:"role"`
SessionID string `json:"sid"`
Exp int64 `json:"exp"`
}
type challengeEntry struct {
ClientID string
ExpiresAt time.Time
}
type Manager struct {
secret []byte
// Login challenges are intentionally process-local ephemeral state. They do
// not belong in SQLite: issuing a challenge is on the hot path when many
// browsers connect at once and must not compete with guesses/presence writes
// for SQLite's single writer lock. Challenges are keyed by the random nonce,
// not by client ID, so two concurrent tabs can each finish their own login.
challengeMu sync.Mutex
challenges map[string]challengeEntry
}
// New keeps the database parameter for source compatibility with older builds.
// Authentication challenge state no longer writes to the database.
func New(_ *sql.DB, secret string) *Manager {
return &Manager{secret: []byte(secret), challenges: make(map[string]challengeEntry)}
}
func ClientID(j PublicJWK) (string, error) {
if j.Kty != "EC" || j.Crv != "P-256" || j.X == "" || j.Y == "" {
return "", errors.New("only P-256 EC JWK is supported")
}
s := j.Kty + "|" + j.Crv + "|" + j.X + "|" + j.Y
h := sha256.Sum256([]byte(s))
return b64.EncodeToString(h[:]), nil
}
func PublicKey(j PublicJWK) (*ecdsa.PublicKey, error) {
xb, err := b64.DecodeString(j.X)
if err != nil {
return nil, err
}
yb, err := b64.DecodeString(j.Y)
if err != nil {
return nil, err
}
x, y := new(big.Int).SetBytes(xb), new(big.Int).SetBytes(yb)
if !elliptic.P256().IsOnCurve(x, y) {
return nil, errors.New("invalid P-256 point")
}
return &ecdsa.PublicKey{Curve: elliptic.P256(), X: x, Y: y}, nil
}
func VerifyRaw(pub *ecdsa.PublicKey, message string, sigB64 string) bool {
sig, err := b64.DecodeString(sigB64)
if err != nil || len(sig) != 64 {
return false
}
h := sha256.Sum256([]byte(message))
r := new(big.Int).SetBytes(sig[:32])
s := new(big.Int).SetBytes(sig[32:])
return ecdsa.Verify(pub, h[:], r, s)
}
func randomB64(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return b64.EncodeToString(b), nil
}
func (m *Manager) NewChallenge(ctx context.Context, cid string) (string, error) {
if err := ctx.Err(); err != nil {
return "", err
}
c, err := randomB64(32)
if err != nil {
return "", err
}
now := time.Now().UTC()
expires := now.Add(2 * time.Minute)
m.challengeMu.Lock()
defer m.challengeMu.Unlock()
// Opportunistic cleanup keeps the map bounded without a maintenance goroutine.
for nonce, entry := range m.challenges {
if !entry.ExpiresAt.After(now) {
delete(m.challenges, nonce)
}
}
m.challenges[c] = challengeEntry{ClientID: cid, ExpiresAt: expires}
return c, nil
}
func (m *Manager) ConsumeChallenge(ctx context.Context, cid, challenge string) error {
if err := ctx.Err(); err != nil {
return err
}
m.challengeMu.Lock()
defer m.challengeMu.Unlock()
entry, ok := m.challenges[challenge]
if !ok {
return errors.New("challenge expired or unknown")
}
// A known challenge is one-shot even if a malformed login tries to consume it.
delete(m.challenges, challenge)
if !entry.ExpiresAt.After(time.Now().UTC()) {
return errors.New("challenge expired")
}
if entry.ClientID != cid {
return errors.New("challenge identity mismatch")
}
return nil
}
func (m *Manager) Issue(cid, role string, ttl time.Duration) (string, Claims, error) {
sid, err := randomB64(18)
if err != nil {
return "", Claims{}, err
}
c := Claims{ClientID: cid, Role: role, SessionID: sid, Exp: time.Now().Add(ttl).Unix()}
hdr := b64.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`))
p, _ := json.Marshal(c)
payload := b64.EncodeToString(p)
unsigned := hdr + "." + payload
mac := hmac.New(sha256.New, m.secret)
mac.Write([]byte(unsigned))
sig := b64.EncodeToString(mac.Sum(nil))
return unsigned + "." + sig, c, nil
}
func (m *Manager) Parse(token string) (Claims, error) {
var c Claims
parts := strings.Split(token, ".")
if len(parts) != 3 {
return c, errors.New("bad token")
}
unsigned := parts[0] + "." + parts[1]
mac := hmac.New(sha256.New, m.secret)
mac.Write([]byte(unsigned))
want := mac.Sum(nil)
got, err := b64.DecodeString(parts[2])
if err != nil || !hmac.Equal(want, got) {
return c, errors.New("bad token signature")
}
pb, err := b64.DecodeString(parts[1])
if err != nil {
return c, err
}
if err := json.Unmarshal(pb, &c); err != nil {
return c, err
}
if c.Exp < time.Now().Unix() {
return c, errors.New("token expired")
}
return c, nil
}
+80
View File
@@ -0,0 +1,80 @@
package auth
import (
"context"
"fmt"
"sync"
"testing"
)
func TestConcurrentChallengesSameIdentityDoNotOverwrite(t *testing.T) {
m := New(nil, "test-secret")
ctx := context.Background()
c1, err := m.NewChallenge(ctx, "client-a")
if err != nil {
t.Fatal(err)
}
c2, err := m.NewChallenge(ctx, "client-a")
if err != nil {
t.Fatal(err)
}
if c1 == c2 {
t.Fatal("expected unique challenges")
}
if err := m.ConsumeChallenge(ctx, "client-a", c1); err != nil {
t.Fatalf("first challenge should remain valid: %v", err)
}
if err := m.ConsumeChallenge(ctx, "client-a", c2); err != nil {
t.Fatalf("second challenge should remain valid: %v", err)
}
}
func TestChallengeIsOneShotAndIdentityBound(t *testing.T) {
m := New(nil, "test-secret")
ctx := context.Background()
c, err := m.NewChallenge(ctx, "client-a")
if err != nil {
t.Fatal(err)
}
if err := m.ConsumeChallenge(ctx, "client-b", c); err == nil {
t.Fatal("challenge must not be consumable by another identity")
}
if err := m.ConsumeChallenge(ctx, "client-a", c); err == nil {
t.Fatal("challenge must be one-shot after a consume attempt")
}
}
func TestManyConcurrentChallenges(t *testing.T) {
m := New(nil, "test-secret")
ctx := context.Background()
const n = 64
type item struct{ cid, challenge string }
out := make(chan item, n)
errCh := make(chan error, n)
var wg sync.WaitGroup
for i := 0; i < n; i++ {
i := i
wg.Add(1)
go func() {
defer wg.Done()
cid := fmt.Sprintf("client-%d", i)
c, err := m.NewChallenge(ctx, cid)
if err != nil {
errCh <- err
return
}
out <- item{cid: cid, challenge: c}
}()
}
wg.Wait()
close(out)
close(errCh)
for err := range errCh {
t.Fatal(err)
}
for x := range out {
if err := m.ConsumeChallenge(ctx, x.cid, x.challenge); err != nil {
t.Fatalf("consume %s: %v", x.cid, err)
}
}
}
+88
View File
@@ -0,0 +1,88 @@
package core
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"fmt"
"math"
"math/big"
)
func RandomDecimal(bits int) (string, error) {
max := new(big.Int).Lsh(big.NewInt(1), uint(bits))
n, err := rand.Int(rand.Reader, max)
if err != nil {
return "", err
}
return n.String(), nil
}
func RandomSeed() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
func ExpectedGuess(taskID, seed, clientID string, seq int64, bits int) string {
h := sha256.Sum256([]byte(fmt.Sprintf("%s|%s|%s|%d", taskID, seed, clientID, seq)))
n := new(big.Int).SetBytes(h[:])
mod := new(big.Int).Lsh(big.NewInt(1), uint(bits))
n.Mod(n, mod)
return n.String()
}
func Distance(a, b string) (*big.Int, error) {
x, ok := new(big.Int).SetString(a, 10)
if !ok {
return nil, fmt.Errorf("invalid integer")
}
y, ok := new(big.Int).SetString(b, 10)
if !ok {
return nil, fmt.Errorf("invalid integer")
}
d := new(big.Int).Sub(x, y)
d.Abs(d)
return d, nil
}
func log2Big(x *big.Int) float64 {
if x.Sign() <= 0 {
return 0
}
n := x.BitLen()
if n <= 53 {
return math.Log2(float64(x.Uint64()))
}
shift := n - 53
top := new(big.Int).Rsh(new(big.Int).Set(x), uint(shift))
return math.Log2(float64(top.Uint64())) + float64(shift)
}
func Score(distance *big.Int, bits int) float64 {
if distance.Sign() == 0 {
return 100
}
s := 100 * (1 - log2Big(new(big.Int).Add(distance, big.NewInt(1)))/float64(bits))
if s < 0 {
return 0
}
if s > 100 {
return 100
}
return s
}
func Position(clientID string, score float64) (float64, float64, float64) {
h := sha256.Sum256([]byte(clientID))
f := func(i int) float64 { v := uint16(h[i])<<8 | uint16(h[i+1]); return float64(v)/65535*2 - 1 }
x, y, z := f(0), f(2), f(4)
norm := math.Sqrt(x*x + y*y + z*z)
if norm < 0.001 {
norm = 1
}
r := 0.25 + 13*(1-score/100)
return r * x / norm, r * y / norm, r * z / norm
}
+29
View File
@@ -0,0 +1,29 @@
package core
import (
"math/big"
"testing"
)
func TestExpectedGuessDeterministic(t *testing.T) {
a := ExpectedGuess("task_x", "seed", "client", 7, 28)
b := ExpectedGuess("task_x", "seed", "client", 7, 28)
if a != b {
t.Fatalf("not deterministic: %s != %s", a, b)
}
n, ok := new(big.Int).SetString(a, 10)
if !ok || n.Sign() < 0 || n.BitLen() > 28 {
t.Fatalf("guess outside range: %s", a)
}
}
func TestScore(t *testing.T) {
if Score(big.NewInt(0), 32) != 100 {
t.Fatal("exact hit must score 100")
}
near := Score(big.NewInt(1), 32)
far := Score(new(big.Int).Lsh(big.NewInt(1), 30), 32)
if near <= far {
t.Fatalf("near score %f should exceed far %f", near, far)
}
}
+285
View File
@@ -0,0 +1,285 @@
package runtime
import (
"errors"
"sync"
"sync/atomic"
"time"
"neuralhunt/internal/data"
)
var (
ErrPresenceConflict = errors.New("identity already connected")
ErrRateLimited = errors.New("rate limited")
ErrBadSequence = errors.New("bad sequence")
)
type guessKey struct{ TaskID, ClientID string }
type presenceEntry struct {
SessionID string
LeaseID uint64
}
type GuessState struct {
NextSeq int64
LastGuess time.Time
BestScore float64
GuessCount int64
PublicSeed string
Revision int64
}
type State struct {
mu sync.RWMutex
presence map[string]presenceEntry
selected map[string]string
guesses map[guessKey]GuessState
started time.Time
presenceSeq atomic.Uint64
guessesTotal atomic.Uint64
improvementsTotal atomic.Uint64
sqliteWritesTotal atomic.Uint64
rejectedTotal atomic.Uint64
rateMu sync.Mutex
sec [60]rateBucket
}
type rateBucket struct {
unix int64
guesses uint64
improvements uint64
sqliteWrites uint64
rejected uint64
}
func New() *State {
return &State{presence: make(map[string]presenceEntry), selected: make(map[string]string), guesses: make(map[guessKey]GuessState), started: time.Now()}
}
// AcquirePresence reserves a client identity for one authenticated browser/session.
// Reconnects from the SAME session are allowed and receive a new lease generation.
// The generation is critical: an old websocket may finish closing after a replacement
// connection has already been established. Its deferred ReleasePresence must not delete
// the newer connection's presence entry.
func (s *State) AcquirePresence(clientID, sessionID string) (uint64, error) {
s.mu.Lock()
defer s.mu.Unlock()
if cur, ok := s.presence[clientID]; ok && cur.SessionID != sessionID {
return 0, ErrPresenceConflict
}
lease := s.presenceSeq.Add(1)
s.presence[clientID] = presenceEntry{SessionID: sessionID, LeaseID: lease}
return lease, nil
}
func (s *State) HasPresence(clientID, sessionID string) bool {
s.mu.RLock()
defer s.mu.RUnlock()
cur, ok := s.presence[clientID]
return ok && cur.SessionID == sessionID
}
func (s *State) ReleasePresence(clientID, sessionID string, leaseID uint64) {
s.mu.Lock()
if cur, ok := s.presence[clientID]; ok && cur.SessionID == sessionID && cur.LeaseID == leaseID {
delete(s.presence, clientID)
}
s.mu.Unlock()
}
func (s *State) IsConnected(clientID string) bool {
s.mu.RLock()
defer s.mu.RUnlock()
_, ok := s.presence[clientID]
return ok
}
func (s *State) ConnectedCount() int64 {
s.mu.RLock()
defer s.mu.RUnlock()
return int64(len(s.presence))
}
func (s *State) SetTaskSelection(clientID, taskID string) {
s.mu.Lock()
if taskID == "" {
delete(s.selected, clientID)
} else {
s.selected[clientID] = taskID
}
s.mu.Unlock()
}
func (s *State) TaskSelection(clientID string) string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.selected[clientID]
}
func (s *State) ReplaceTaskSelection(oldTaskID, newTaskID string) {
s.mu.Lock()
for cid, taskID := range s.selected {
if taskID == oldTaskID {
s.selected[cid] = newTaskID
}
}
s.mu.Unlock()
}
func (s *State) InitGuess(task data.Task, cid string, persisted GuessState) GuessState {
k := guessKey{task.ID, cid}
s.mu.Lock()
defer s.mu.Unlock()
if cur, ok := s.guesses[k]; ok {
// Preserve the hot-path sequence across admin changes that keep the same seed.
// A reroll changes public_seed, which intentionally reloads the persisted reset.
if cur.PublicSeed == task.PublicSeed {
cur.Revision = task.Revision
s.guesses[k] = cur
return cur
}
}
persisted.PublicSeed = task.PublicSeed
persisted.Revision = task.Revision
s.guesses[k] = persisted
return persisted
}
func (s *State) Current(task data.Task, cid string) (GuessState, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
g, ok := s.guesses[guessKey{task.ID, cid}]
if !ok || g.PublicSeed != task.PublicSeed {
return GuessState{}, false
}
return g, true
}
type AcceptResult struct {
State GuessState
Previous GuessState
Improved bool
}
func (s *State) Accept(task data.Task, cid string, seq int64, score float64, minInterval time.Duration) (AcceptResult, error) {
k := guessKey{task.ID, cid}
now := time.Now().UTC()
s.mu.Lock()
g, ok := s.guesses[k]
if !ok || g.PublicSeed != task.PublicSeed {
s.mu.Unlock()
return AcceptResult{}, ErrBadSequence
}
if seq != g.NextSeq {
s.mu.Unlock()
s.record(false, false, true)
return AcceptResult{}, ErrBadSequence
}
prev := g
if !g.LastGuess.IsZero() && minInterval > 0 && now.Sub(g.LastGuess) < minInterval {
s.mu.Unlock()
s.record(false, false, true)
return AcceptResult{}, ErrRateLimited
}
g.NextSeq++
g.GuessCount++
g.LastGuess = now
improved := score > g.BestScore
if improved {
g.BestScore = score
}
g.Revision = task.Revision
s.guesses[k] = g
s.mu.Unlock()
s.record(true, improved, false)
return AcceptResult{State: g, Previous: prev, Improved: improved}, nil
}
func (s *State) Restore(task data.Task, cid string, acceptedNextSeq int64, previous GuessState) {
k := guessKey{task.ID, cid}
s.mu.Lock()
if cur, ok := s.guesses[k]; ok && cur.NextSeq == acceptedNextSeq && cur.PublicSeed == task.PublicSeed {
s.guesses[k] = previous
}
s.mu.Unlock()
}
func (s *State) MarkSQLiteWrite() {
s.sqliteWritesTotal.Add(1)
s.rateMu.Lock()
b := s.bucketLocked(time.Now().Unix())
b.sqliteWrites++
s.rateMu.Unlock()
}
func (s *State) record(guess, improvement, rejected bool) {
if guess {
s.guessesTotal.Add(1)
}
if improvement {
s.improvementsTotal.Add(1)
}
if rejected {
s.rejectedTotal.Add(1)
}
s.rateMu.Lock()
b := s.bucketLocked(time.Now().Unix())
if guess {
b.guesses++
}
if improvement {
b.improvements++
}
if rejected {
b.rejected++
}
s.rateMu.Unlock()
}
func (s *State) bucketLocked(sec int64) *rateBucket {
i := sec % 60
if s.sec[i].unix != sec {
s.sec[i] = rateBucket{unix: sec}
}
return &s.sec[i]
}
func (s *State) Rates(window int64) (guesses, improvements, sqliteWrites, rejected float64) {
if window < 1 {
window = 5
}
if window > 60 {
window = 60
}
now := time.Now().Unix()
var g, i, w, r uint64
s.rateMu.Lock()
for x := range s.sec {
b := s.sec[x]
if b.unix > now-window {
g += b.guesses
i += b.improvements
w += b.sqliteWrites
r += b.rejected
}
}
s.rateMu.Unlock()
d := float64(window)
return float64(g) / d, float64(i) / d, float64(w) / d, float64(r) / d
}
type Metrics struct {
Connected int64 `json:"connected"`
GuessesPerSec float64 `json:"guesses_per_sec"`
ImprovementsPerSec float64 `json:"improvements_per_sec"`
SQLiteWritesPerSec float64 `json:"sqlite_writes_per_sec"`
RejectedPerSec float64 `json:"rejected_per_sec"`
GuessesTotal uint64 `json:"guesses_total"`
ImprovementsTotal uint64 `json:"improvements_total"`
SQLiteWritesTotal uint64 `json:"sqlite_writes_total"`
RejectedTotal uint64 `json:"rejected_total"`
UptimeSec int64 `json:"uptime_sec"`
}
func (s *State) Metrics() Metrics {
g, i, w, r := s.Rates(5)
return Metrics{Connected: s.ConnectedCount(), GuessesPerSec: g, ImprovementsPerSec: i, SQLiteWritesPerSec: w, RejectedPerSec: r, GuessesTotal: s.guessesTotal.Load(), ImprovementsTotal: s.improvementsTotal.Load(), SQLiteWritesTotal: s.sqliteWritesTotal.Load(), RejectedTotal: s.rejectedTotal.Load(), UptimeSec: int64(time.Since(s.started).Seconds())}
}
+81
View File
@@ -0,0 +1,81 @@
package runtime
import (
"testing"
"time"
"neuralhunt/internal/data"
)
func TestPresenceSingleSession(t *testing.T) {
s := New()
lease1, err := s.AcquirePresence("c", "s1")
if err != nil {
t.Fatal(err)
}
if _, err := s.AcquirePresence("c", "s2"); err == nil {
t.Fatal("expected conflict")
}
if !s.HasPresence("c", "s1") {
t.Fatal("missing presence")
}
s.ReleasePresence("c", "s1", lease1)
if s.ConnectedCount() != 0 {
t.Fatal("presence not released")
}
}
func TestPresenceReconnectOldReleaseCannotDeleteNewLease(t *testing.T) {
s := New()
oldLease, err := s.AcquirePresence("c", "same-session")
if err != nil {
t.Fatal(err)
}
newLease, err := s.AcquirePresence("c", "same-session")
if err != nil {
t.Fatal(err)
}
if oldLease == newLease {
t.Fatal("expected a new lease generation")
}
// This is the race that caused periodic HTTP 409 responses: the old
// websocket's defer ran after the replacement websocket had connected.
s.ReleasePresence("c", "same-session", oldLease)
if !s.HasPresence("c", "same-session") {
t.Fatal("stale websocket release removed the replacement presence")
}
s.ReleasePresence("c", "same-session", newLease)
if s.ConnectedCount() != 0 {
t.Fatal("new lease was not released")
}
}
func TestGuessHotPathAndReroll(t *testing.T) {
s := New()
task := data.Task{ID: "t", PublicSeed: "a", Revision: 1}
s.InitGuess(task, "c", GuessState{})
r, err := s.Accept(task, "c", 0, 12.5, time.Millisecond)
if err != nil {
t.Fatal(err)
}
if !r.Improved || r.State.NextSeq != 1 || r.State.GuessCount != 1 {
t.Fatalf("bad state: %+v", r)
}
if _, err = s.Accept(task, "c", 1, 10, time.Hour); err != ErrRateLimited {
t.Fatalf("want rate limit, got %v", err)
}
// Same seed/admin revision preserves sequence.
task.Revision = 2
g := s.InitGuess(task, "c", GuessState{})
if g.NextSeq != 1 {
t.Fatalf("sequence lost: %d", g.NextSeq)
}
// New seed is a reroll and reloads the persisted reset.
task.PublicSeed = "b"
g = s.InitGuess(task, "c", GuessState{})
if g.NextSeq != 0 || g.BestScore != 0 {
t.Fatalf("reroll not reset: %+v", g)
}
}
+254
View File
@@ -0,0 +1,254 @@
package server
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"image"
_ "image/jpeg"
_ "image/png"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"neuralhunt/internal/artifact"
wsx "neuralhunt/internal/ws"
"github.com/go-chi/chi/v5"
)
const maxStyleReferenceBytes = 12 << 20
func (s *Server) adminCreateCharacterAnchor(w http.ResponseWriter, r *http.Request) {
if s.artifactWorker == nil {
jsonOut(w, http.StatusServiceUnavailable, map[string]string{"error": "artifact worker unavailable"})
return
}
if err := s.artifactWorker.CreateCharacterAnchor(r.Context()); err != nil {
if errors.Is(err, artifact.ErrCharacterAnchorExists) {
jsonOut(w, http.StatusConflict, map[string]string{"error": "character anchor already exists"})
return
}
jsonOut(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
jsonOut(w, http.StatusCreated, map[string]any{
"ok": true,
"url": "/api/admin/artifact/character-anchor?ts=" + fmt.Sprint(time.Now().UnixMilli()),
})
}
func (s *Server) adminCharacterAnchorFile(w http.ResponseWriter, r *http.Request) {
path := filepath.Join(s.artifactDir, "_collection", "character_anchor.png")
b, err := os.ReadFile(path)
if err != nil || len(b) <= 1024 {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Cache-Control", "private, no-store")
w.Header().Set("Content-Length", fmt.Sprint(len(b)))
_, _ = w.Write(b)
}
func normalizeUploadedStyle(raw []byte) (ext, contentType string, width, height int, err error) {
if len(raw) < 128 {
return "", "", 0, 0, errors.New("style reference is empty")
}
contentType = strings.Split(http.DetectContentType(raw), ";")[0]
switch contentType {
case "image/jpeg":
ext = ".jpg"
case "image/png":
ext = ".png"
default:
return "", "", 0, 0, fmt.Errorf("unsupported style image type %q; use JPEG or PNG", contentType)
}
cfg, _, err := image.DecodeConfig(bytes.NewReader(raw))
if err != nil {
return "", "", 0, 0, fmt.Errorf("decode style image: %w", err)
}
if cfg.Width < 128 || cfg.Height < 128 || cfg.Width > 8192 || cfg.Height > 8192 || int64(cfg.Width)*int64(cfg.Height) > 40_000_000 {
return "", "", 0, 0, fmt.Errorf("style image dimensions %dx%d are outside the allowed range", cfg.Width, cfg.Height)
}
return ext, contentType, cfg.Width, cfg.Height, nil
}
func writeStyleAsset(dir, name string, raw []byte) error {
if err := os.MkdirAll(dir, 0o750); err != nil {
return err
}
target := filepath.Join(dir, name)
if st, err := os.Stat(target); err == nil && st.Size() == int64(len(raw)) {
// The filename is a SHA-256 of the file contents, so an existing asset
// with the same name is the same immutable reference. This also avoids
// Windows rename-over-existing behavior.
return nil
}
tmp, err := os.CreateTemp(dir, ".style-*")
if err != nil {
return err
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if err := tmp.Chmod(0o640); err != nil {
tmp.Close()
return err
}
if _, err := tmp.Write(raw); err != nil {
tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmpName, target)
}
func (s *Server) adminTaskStyleReferencePut(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
r.Body = http.MaxBytesReader(w, r.Body, maxStyleReferenceBytes+1<<20)
if err := r.ParseMultipartForm(maxStyleReferenceBytes); err != nil {
jsonOut(w, http.StatusBadRequest, map[string]string{"error": "invalid multipart upload: " + err.Error()})
return
}
f, _, err := r.FormFile("file")
if err != nil {
jsonOut(w, http.StatusBadRequest, map[string]string{"error": "multipart field 'file' is required"})
return
}
defer f.Close()
raw, err := io.ReadAll(io.LimitReader(f, maxStyleReferenceBytes+1))
if err != nil {
jsonOut(w, http.StatusBadRequest, map[string]string{"error": "read upload: " + err.Error()})
return
}
if len(raw) > maxStyleReferenceBytes {
jsonOut(w, http.StatusRequestEntityTooLarge, map[string]string{"error": "style reference exceeds 12 MiB"})
return
}
ext, contentType, width, height, err := normalizeUploadedStyle(raw)
if err != nil {
jsonOut(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
h := sha256.Sum256(raw)
name := hex.EncodeToString(h[:]) + ext
if err := writeStyleAsset(filepath.Join(s.artifactDir, "_styles"), name, raw); err != nil {
jsonOut(w, http.StatusInternalServerError, map[string]string{"error": "store style reference: " + err.Error()})
return
}
if err := s.store.SetTaskStyleReference(r.Context(), id, name); err != nil {
jsonOut(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
_ = s.hub.Publish(r.Context(), wsx.Event{Type: "task_changed", TaskID: id, Data: map[string]string{"action": "style_reference"}})
jsonOut(w, http.StatusOK, map[string]any{
"ok": true,
"name": name,
"sha256": hex.EncodeToString(h[:]),
"content_type": contentType,
"width": width,
"height": height,
"url": "/api/admin/tasks/" + id + "/style-reference?ts=" + fmt.Sprint(time.Now().UnixMilli()),
})
}
func (s *Server) adminTaskStyleReferenceDelete(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if err := s.store.SetTaskStyleReference(r.Context(), id, ""); err != nil {
jsonOut(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
_ = s.hub.Publish(r.Context(), wsx.Event{Type: "task_changed", TaskID: id, Data: map[string]string{"action": "style_reference"}})
jsonOut(w, http.StatusOK, map[string]bool{"ok": true})
}
func serveTaskStyleReference(w http.ResponseWriter, r *http.Request, ref artifact.TaskStyleReference, cache string) {
if len(ref.Bytes) == 0 {
http.NotFound(w, r)
return
}
ct := ref.ContentType
if ct == "" {
ct = http.DetectContentType(ref.Bytes)
}
w.Header().Set("Content-Type", ct)
w.Header().Set("Cache-Control", cache)
w.Header().Set("ETag", `"`+ref.SHA256+`"`)
w.Header().Set("Content-Length", fmt.Sprint(len(ref.Bytes)))
_, _ = w.Write(ref.Bytes)
}
func (s *Server) adminTaskStyleReferenceFile(w http.ResponseWriter, r *http.Request) {
if s.artifactWorker == nil {
http.NotFound(w, r)
return
}
ref, err := s.artifactWorker.TaskStyleReference(r.Context(), chi.URLParam(r, "id"))
if err != nil {
http.NotFound(w, r)
return
}
serveTaskStyleReference(w, r, ref, "private, no-store")
}
func (s *Server) publicTaskStyleReference(w http.ResponseWriter, r *http.Request) {
if s.artifactWorker == nil {
http.NotFound(w, r)
return
}
id := chi.URLParam(r, "id")
var status string
if err := s.store.DB.QueryRowContext(r.Context(), `SELECT status FROM tasks WHERE id=?`, id).Scan(&status); err != nil || status != "active" {
http.NotFound(w, r)
return
}
ref, err := s.artifactWorker.TaskStyleReference(r.Context(), id)
if err != nil {
http.NotFound(w, r)
return
}
serveTaskStyleReference(w, r, ref, "public, max-age=300")
}
func (s *Server) adminCreatePipelineTestCard(w http.ResponseWriter, r *http.Request) {
if s.artifactWorker == nil {
jsonOut(w, http.StatusServiceUnavailable, map[string]string{"error": "artifact worker unavailable"})
return
}
id := chi.URLParam(r, "id")
path, err := s.artifactWorker.CreatePipelineTestCard(r.Context(), id)
if err != nil {
jsonOut(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
_ = path
jsonOut(w, http.StatusCreated, map[string]any{"ok": true, "url": "/api/admin/tasks/" + id + "/pipeline-test/card?ts=" + fmt.Sprint(time.Now().UnixMilli()), "api_calls": 0, "cost_usd": 0})
}
func (s *Server) adminPipelineTestCardFile(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if filepath.Base(id) != id || strings.ContainsAny(id, `/\\`) {
http.NotFound(w, r)
return
}
path := filepath.Join(s.artifactDir, "_test", id, "image.svg")
b, err := os.ReadFile(path)
if err != nil || len(b) < 128 {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "image/svg+xml")
w.Header().Set("Cache-Control", "private, no-store")
_, _ = w.Write(b)
}
+120
View File
@@ -0,0 +1,120 @@
package server
import (
"database/sql"
"net/http"
"strconv"
"time"
)
type artifactUsageRow struct {
CreatedAt time.Time `json:"created_at"`
TaskID string `json:"task_id,omitempty"`
Kind string `json:"kind"`
Model string `json:"model"`
Endpoint string `json:"endpoint"`
Size string `json:"size"`
Quality string `json:"quality"`
RequestID string `json:"request_id,omitempty"`
InputTokens int64 `json:"input_tokens"`
InputTextTokens int64 `json:"input_text_tokens"`
InputImageTokens int64 `json:"input_image_tokens"`
OutputTokens int64 `json:"output_tokens"`
TotalTokens int64 `json:"total_tokens"`
EstimatedCostUSD *float64 `json:"estimated_cost_usd,omitempty"`
PricingBasis string `json:"pricing_basis,omitempty"`
}
func (s *Server) adminArtifactUsage(w http.ResponseWriter, r *http.Request) {
now := time.Now().UTC()
dayStartMS := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).UnixMilli()
if raw := r.URL.Query().Get("day_start_ms"); raw != "" {
if v, err := strconv.ParseInt(raw, 10, 64); err == nil {
// Accept a browser-local midnight in a reasonable window. This lets the
// "today" KPI follow the admin's local calendar day without adding a
// timezone setting to the server.
candidate := time.UnixMilli(v).UTC()
if candidate.After(now.Add(-48*time.Hour)) && candidate.Before(now.Add(24*time.Hour)) {
dayStartMS = v
}
}
}
var todayCalls, todayCards, todayPriced int64
var todayCost float64
_ = s.store.DB.QueryRowContext(r.Context(), `SELECT count(*),
COALESCE(sum(CASE WHEN kind='artifact' THEN 1 ELSE 0 END),0),
COALESCE(sum(CASE WHEN estimated_cost_usd IS NOT NULL THEN 1 ELSE 0 END),0),
COALESCE(sum(estimated_cost_usd),0)
FROM artifact_api_usage WHERE created_at>=?`, dayStartMS).Scan(&todayCalls, &todayCards, &todayPriced, &todayCost)
var cardCalls, pricedCardCalls int64
var totalCardCost, avgCardCost float64
var inputTokens, inputTextTokens, inputImageTokens, outputTokens, totalTokens int64
_ = s.store.DB.QueryRowContext(r.Context(), `SELECT count(*),count(estimated_cost_usd),
COALESCE(sum(estimated_cost_usd),0),COALESCE(avg(estimated_cost_usd),0),
COALESCE(sum(input_tokens),0),COALESCE(sum(input_text_tokens),0),COALESCE(sum(input_image_tokens),0),
COALESCE(sum(output_tokens),0),COALESCE(sum(total_tokens),0)
FROM artifact_api_usage WHERE kind='artifact'`).Scan(
&cardCalls, &pricedCardCalls, &totalCardCost, &avgCardCost,
&inputTokens, &inputTextTokens, &inputImageTokens, &outputTokens, &totalTokens)
var anchorCalls int64
var anchorCost float64
_ = s.store.DB.QueryRowContext(r.Context(), `SELECT count(*),COALESCE(sum(estimated_cost_usd),0)
FROM artifact_api_usage WHERE kind='character_anchor'`).Scan(&anchorCalls, &anchorCost)
recent := make([]artifactUsageRow, 0, 20)
rows, err := s.store.DB.QueryContext(r.Context(), `SELECT created_at,task_id,kind,model,endpoint,size,quality,request_id,
input_tokens,input_text_tokens,input_image_tokens,output_tokens,total_tokens,estimated_cost_usd,pricing_basis
FROM artifact_api_usage ORDER BY created_at DESC,id DESC LIMIT 20`)
if err == nil {
defer rows.Close()
for rows.Next() {
var createdMS int64
var taskID, requestID sql.NullString
var cost sql.NullFloat64
var row artifactUsageRow
if err := rows.Scan(&createdMS, &taskID, &row.Kind, &row.Model, &row.Endpoint, &row.Size, &row.Quality, &requestID,
&row.InputTokens, &row.InputTextTokens, &row.InputImageTokens, &row.OutputTokens, &row.TotalTokens, &cost, &row.PricingBasis); err != nil {
continue
}
row.CreatedAt = time.UnixMilli(createdMS).UTC()
if taskID.Valid {
row.TaskID = taskID.String
}
if requestID.Valid {
row.RequestID = requestID.String
}
if cost.Valid {
v := cost.Float64
row.EstimatedCostUSD = &v
}
recent = append(recent, row)
}
}
jsonOut(w, 200, map[string]any{
"day_start": time.UnixMilli(dayStartMS).UTC(),
"today_calls": todayCalls,
"today_cards": todayCards,
"today_priced_calls": todayPriced,
"today_cost_usd": todayCost,
"card_generations": cardCalls,
"priced_card_generations": pricedCardCalls,
"total_card_cost_usd": totalCardCost,
"avg_card_cost_usd": avgCardCost,
"cost_per_1000_usd": avgCardCost * 1000,
"anchor_generations": anchorCalls,
"anchor_cost_usd": anchorCost,
"card_usage": map[string]int64{
"input_tokens": inputTokens,
"input_text_tokens": inputTextTokens,
"input_image_tokens": inputImageTokens,
"output_tokens": outputTokens,
"total_tokens": totalTokens,
},
"cost_method": "estimated from provider-reported token usage using pinned OpenAI standard public token rates; not invoice reconciliation",
"recent": recent,
})
}
+31
View File
@@ -0,0 +1,31 @@
package server
import (
"net/http/httptest"
"strings"
"testing"
"neuralhunt/internal/auth"
)
func TestDecodeAuthAcceptsExtraJWKMembers(t *testing.T) {
body := `{"public_jwk":{"kty":"EC","crv":"P-256","x":"abc","y":"def","ext":true,"key_ops":["verify"],"alg":"ES256","use":"sig","kid":"browser-key","vendor_future_field":"ok"}}`
req := httptest.NewRequest("POST", "/api/auth/challenge", strings.NewReader(body))
var in struct {
PublicJWK auth.PublicJWK `json:"public_jwk"`
}
if err := decodeAuth(req, &in); err != nil {
t.Fatalf("decodeAuth should tolerate optional/unknown JWK metadata: %v", err)
}
if in.PublicJWK.Kty != "EC" || in.PublicJWK.Crv != "P-256" || in.PublicJWK.X != "abc" || in.PublicJWK.Y != "def" {
t.Fatalf("core JWK fields were not decoded: %+v", in.PublicJWK)
}
}
func TestDecodeAuthRejectsSecondJSONValue(t *testing.T) {
req := httptest.NewRequest("POST", "/api/auth/challenge", strings.NewReader(`{"public_jwk":{}} {"x":1}`))
var in any
if err := decodeAuth(req, &in); err == nil {
t.Fatal("expected second JSON value to be rejected")
}
}
File diff suppressed because it is too large Load Diff
+213
View File
@@ -0,0 +1,213 @@
package server
import (
"bytes"
"fmt"
"html"
"image"
"image/color"
"image/draw"
_ "image/gif"
_ "image/jpeg"
"image/png"
"net/url"
"os"
"path/filepath"
"strings"
)
// artifactLocalPath maps an artifact URI emitted by the worker back into the
// configured artifact directory. It deliberately rejects traversal and files
// outside /artifacts/ so the public preview endpoint cannot become a generic
// file reader.
func artifactLocalPath(root, artifactURI string) (string, error) {
if strings.TrimSpace(root) == "" {
return "", fmt.Errorf("artifact storage disabled")
}
u, err := url.Parse(artifactURI)
if err != nil {
return "", err
}
p := u.Path
const prefix = "/artifacts/"
i := strings.Index(p, prefix)
if i < 0 {
return "", fmt.Errorf("artifact URI outside artifact namespace")
}
rel := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(p[i:], prefix)))
if rel == "." || rel == "" || filepath.IsAbs(rel) || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("invalid artifact path")
}
rootAbs, err := filepath.Abs(root)
if err != nil {
return "", err
}
candidate := filepath.Join(rootAbs, rel)
candidateAbs, err := filepath.Abs(candidate)
if err != nil {
return "", err
}
check, err := filepath.Rel(rootAbs, candidateAbs)
if err != nil || check == ".." || strings.HasPrefix(check, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("artifact path escapes storage")
}
return candidateAbs, nil
}
func watermarkPreviewFile(path, label string) ([]byte, string, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, "", err
}
if len(b) > 64<<20 {
return nil, "", fmt.Errorf("artifact too large for preview")
}
ext := strings.ToLower(filepath.Ext(path))
if ext == ".svg" || bytes.Contains(bytes.ToLower(b[:minInt(len(b), 512)]), []byte("<svg")) {
out, err := watermarkSVG(b, label)
return out, "image/svg+xml; charset=utf-8", err
}
img, _, err := image.Decode(bytes.NewReader(b))
if err != nil {
return nil, "", fmt.Errorf("decode preview image: %w", err)
}
bounds := img.Bounds()
if bounds.Dx() < 1 || bounds.Dy() < 1 {
return nil, "", fmt.Errorf("empty artifact image")
}
dst := image.NewNRGBA(bounds)
draw.Draw(dst, bounds, img, bounds.Min, draw.Src)
drawRasterWatermark(dst, label)
var out bytes.Buffer
if err := png.Encode(&out, dst); err != nil {
return nil, "", err
}
return out.Bytes(), "image/png", nil
}
func watermarkSVG(src []byte, label string) ([]byte, error) {
s := string(src)
i := strings.LastIndex(strings.ToLower(s), "</svg>")
if i < 0 {
return nil, fmt.Errorf("invalid svg artifact")
}
label = html.EscapeString(strings.ToUpper(strings.TrimSpace(label)))
if label == "" {
label = "NEURAL HUNT PREVIEW"
}
overlay := fmt.Sprintf(`<defs><pattern id="nh-preview-watermark" width="420" height="190" patternUnits="userSpaceOnUse" patternTransform="rotate(-24)"><text x="18" y="96" fill="white" fill-opacity="0.16" font-family="system-ui,sans-serif" font-size="28" font-weight="800" letter-spacing="5">%s</text></pattern></defs><rect x="0" y="0" width="100%%" height="100%%" fill="url(#nh-preview-watermark)" pointer-events="none"/><rect x="2%%" y="92%%" width="96%%" height="5%%" rx="12" fill="black" fill-opacity="0.30"/><text x="50%%" y="95.5%%" text-anchor="middle" fill="white" fill-opacity="0.72" font-family="system-ui,sans-serif" font-size="18" font-weight="800" letter-spacing="4">WATERMARKED LEADERBOARD PREVIEW</text>`, label)
return []byte(s[:i] + overlay + s[i:]), nil
}
var pixelFont = map[rune][7]string{
'A': {"01110", "10001", "10001", "11111", "10001", "10001", "10001"},
'E': {"11111", "10000", "10000", "11110", "10000", "10000", "11111"},
'H': {"10001", "10001", "10001", "11111", "10001", "10001", "10001"},
'I': {"11111", "00100", "00100", "00100", "00100", "00100", "11111"},
'L': {"10000", "10000", "10000", "10000", "10000", "10000", "11111"},
'N': {"10001", "11001", "10101", "10101", "10011", "10001", "10001"},
'P': {"11110", "10001", "10001", "11110", "10000", "10000", "10000"},
'R': {"11110", "10001", "10001", "11110", "10100", "10010", "10001"},
'T': {"11111", "00100", "00100", "00100", "00100", "00100", "00100"},
'U': {"10001", "10001", "10001", "10001", "10001", "10001", "01110"},
'V': {"10001", "10001", "10001", "10001", "10001", "01010", "00100"},
'W': {"10001", "10001", "10001", "10101", "10101", "10101", "01010"},
}
func drawRasterWatermark(img *image.NRGBA, label string) {
b := img.Bounds()
w, h := b.Dx(), b.Dy()
if w < 1 || h < 1 {
return
}
label = strings.ToUpper(strings.TrimSpace(label))
if label == "" {
label = "NEURAL HUNT PREVIEW"
}
// Keep the bitmap watermark large enough to survive thumbnail scaling.
scale := minInt(w, h) / 360
if scale < 1 {
scale = 1
}
if scale > 6 {
scale = 6
}
textW := pixelTextWidth(label, scale)
rowStep := 72 * scale
colStep := textW + 54*scale
for y, row := b.Min.Y+18*scale, 0; y < b.Max.Y; y, row = y+rowStep, row+1 {
offset := 0
if row%2 == 1 {
offset = -(colStep / 2)
}
for x := b.Min.X + offset; x < b.Max.X; x += colStep {
drawPixelText(img, x+scale, y+scale, label, scale, color.NRGBA{0, 0, 0, 72})
drawPixelText(img, x, y, label, scale, color.NRGBA{255, 255, 255, 48})
}
}
// Strong lower preview band so cropped screenshots still visibly carry a
// watermark. It contains a repeated NH glyph rather than metadata.
bandH := maxInt(16*scale, h/18)
bandY := b.Max.Y - bandH
draw.Draw(img, image.Rect(b.Min.X, bandY, b.Max.X, b.Max.Y), &image.Uniform{C: color.NRGBA{0, 0, 0, 92}}, image.Point{}, draw.Over)
for x := b.Min.X + 8*scale; x < b.Max.X; x += 40 * scale {
drawPixelText(img, x, bandY+4*scale, "NH", scale, color.NRGBA{255, 255, 255, 118})
}
}
func pixelTextWidth(text string, scale int) int {
if scale < 1 {
scale = 1
}
w := 0
for _, r := range text {
if r == ' ' {
w += 4 * scale
} else {
w += 6 * scale
}
}
return w
}
func drawPixelText(dst draw.Image, x, y int, text string, scale int, col color.Color) {
if scale < 1 {
scale = 1
}
cx := x
for _, r := range text {
if r == ' ' {
cx += 4 * scale
continue
}
glyph, ok := pixelFont[r]
if !ok {
cx += 6 * scale
continue
}
for gy, row := range glyph {
for gx, bit := range row {
if bit != '1' {
continue
}
rect := image.Rect(cx+gx*scale, y+gy*scale, cx+(gx+1)*scale, y+(gy+1)*scale)
draw.Draw(dst, rect, &image.Uniform{C: col}, image.Point{}, draw.Over)
}
}
cx += 6 * scale
}
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
+65
View File
@@ -0,0 +1,65 @@
package server
import (
"bytes"
"image"
"image/color"
"image/png"
"os"
"path/filepath"
"strings"
"testing"
)
func TestArtifactLocalPathRejectsTraversal(t *testing.T) {
root := t.TempDir()
if _, err := artifactLocalPath(root, "/artifacts/../secret.txt"); err == nil {
t.Fatal("expected traversal rejection")
}
got, err := artifactLocalPath(root, "/artifacts/a/image.png")
if err != nil {
t.Fatal(err)
}
want := filepath.Join(root, "a", "image.png")
if got != want {
t.Fatalf("got %q want %q", got, want)
}
}
func TestWatermarkSVG(t *testing.T) {
out, err := watermarkSVG([]byte(`<svg xmlns="http://www.w3.org/2000/svg"><rect width="10" height="10"/></svg>`), "NEURAL HUNT PREVIEW")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(out), "WATERMARKED LEADERBOARD PREVIEW") || !strings.Contains(string(out), "nh-preview-watermark") {
t.Fatalf("watermark missing: %s", out)
}
}
func TestWatermarkRasterProducesPNG(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "source.png")
img := image.NewNRGBA(image.Rect(0, 0, 320, 240))
for y := 0; y < 240; y++ {
for x := 0; x < 320; x++ {
img.Set(x, y, color.NRGBA{20, 40, 60, 255})
}
}
var src bytes.Buffer
if err := png.Encode(&src, img); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, src.Bytes(), 0o600); err != nil {
t.Fatal(err)
}
out, ct, err := watermarkPreviewFile(path, "NEURAL HUNT PREVIEW")
if err != nil {
t.Fatal(err)
}
if ct != "image/png" || len(out) == 0 {
t.Fatalf("unexpected preview %q %d", ct, len(out))
}
if bytes.Equal(out, src.Bytes()) {
t.Fatal("preview should differ from source")
}
}
+261
View File
@@ -0,0 +1,261 @@
package settings
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"os"
"strconv"
"strings"
"sync"
"time"
)
type Runtime struct {
GuessMinIntervalSec int `json:"guess_min_interval_sec"`
ClientSubmitIntervalSec int `json:"client_submit_interval_sec"`
TaskRangeBits int `json:"task_range_bits"`
ActiveTaskCount int `json:"active_task_count"`
PresenceTTLSec int `json:"presence_ttl_sec"`
DefaultMaxNodes int `json:"default_max_nodes"`
PublicScorePrecision int `json:"public_score_precision"`
ArtifactPreset string `json:"artifact_preset"`
ArtifactProvider string `json:"artifact_provider"`
ArtifactModel string `json:"artifact_model"`
ArtifactPrompt string `json:"artifact_prompt"`
ArtifactNegativePrompt string `json:"artifact_negative_prompt"`
ArtifactWidth int `json:"artifact_width"`
ArtifactHeight int `json:"artifact_height"`
ArtifactSteps int `json:"artifact_steps"`
ArtifactQuality string `json:"artifact_quality"`
}
type Manager struct {
db *sql.DB
mu sync.RWMutex
v Runtime
}
func envInt(k string, def int) int {
if s := os.Getenv(k); s != "" {
if n, err := strconv.Atoi(s); err == nil {
return n
}
}
return def
}
func envString(k, def string) string {
if s := strings.TrimSpace(os.Getenv(k)); s != "" {
return s
}
return def
}
func Defaults() Runtime {
return Runtime{
GuessMinIntervalSec: envInt("DEFAULT_GUESS_MIN_INTERVAL_SEC", 10),
ClientSubmitIntervalSec: envInt("DEFAULT_CLIENT_SUBMIT_INTERVAL_SEC", 11),
TaskRangeBits: envInt("DEFAULT_TASK_RANGE_BITS", 28),
ActiveTaskCount: envInt("DEFAULT_ACTIVE_TASK_COUNT", 1),
PresenceTTLSec: envInt("DEFAULT_PRESENCE_TTL_SEC", 35),
DefaultMaxNodes: envInt("DEFAULT_MAX_NODES", 2000),
PublicScorePrecision: envInt("DEFAULT_PUBLIC_SCORE_PRECISION", 2),
ArtifactPreset: strings.ToLower(envString("ARTIFACT_PRESET", "raccoon_full_art_v1")),
ArtifactProvider: strings.ToLower(envString("ARTIFACT_PROVIDER", "openai")),
ArtifactModel: envString("ARTIFACT_MODEL", "gpt-image-2"),
ArtifactPrompt: envString("ARTIFACT_PROMPT", "Optional global creative override for legacy artifact providers. The raccoon_full_art_v1 preset uses its built-in structured master prompt and task-specific NFT instructions."),
ArtifactNegativePrompt: envString("ARTIFACT_NEGATIVE_PROMPT", "text, watermark, logo, signature, low quality, blurry, malformed"),
ArtifactWidth: envInt("ARTIFACT_WIDTH", 1024),
ArtifactHeight: envInt("ARTIFACT_HEIGHT", 1536),
ArtifactSteps: envInt("ARTIFACT_STEPS", 28),
ArtifactQuality: strings.ToLower(envString("ARTIFACT_QUALITY", "medium")),
}
}
func New(ctx context.Context, db *sql.DB) (*Manager, error) {
m := &Manager{db: db, v: Defaults()}
b, _ := json.Marshal(m.v)
now := time.Now().UTC().UnixMilli()
_, err := db.ExecContext(ctx, `INSERT INTO settings(key,value,updated_at) VALUES('runtime',?,?) ON CONFLICT(key) DO NOTHING`, string(b), now)
if err != nil {
return nil, err
}
if err := m.Refresh(ctx); err != nil {
return nil, err
}
if err := m.migrateRIFTMediumDefault(ctx); err != nil {
return nil, err
}
return m, nil
}
// migrateRIFTMediumDefault upgrades deployments created by the first RIFT
// release, where high quality was hard-coded as the collection default. The
// migration runs once only; after that an operator can still deliberately set
// high (or low) without it being overwritten on restart.
func (m *Manager) migrateRIFTMediumDefault(ctx context.Context) error {
const marker = "migration_rift_medium_quality_v1"
var n int
if err := m.db.QueryRowContext(ctx, `SELECT count(*) FROM settings WHERE key=?`, marker).Scan(&n); err != nil {
return err
}
if n > 0 {
return nil
}
// A deployment that explicitly configured ARTIFACT_QUALITY keeps that
// operator choice. The migration only changes the former implicit RIFT
// default.
if strings.TrimSpace(os.Getenv("ARTIFACT_QUALITY")) != "" {
_, err := m.db.ExecContext(ctx, `INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO NOTHING`, marker, "explicit-env-preserved", time.Now().UTC().UnixMilli())
return err
}
v := m.Get()
if strings.EqualFold(strings.TrimSpace(v.ArtifactPreset), "raccoon_full_art_v1") && strings.EqualFold(strings.TrimSpace(v.ArtifactQuality), "high") {
v.ArtifactQuality = "medium"
b, _ := json.Marshal(v)
now := time.Now().UTC().UnixMilli()
if _, err := m.db.ExecContext(ctx, `UPDATE settings SET value=?,updated_at=? WHERE key='runtime'`, string(b), now); err != nil {
return err
}
m.mu.Lock()
m.v = v
m.mu.Unlock()
}
_, err := m.db.ExecContext(ctx, `INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO NOTHING`, marker, "done", time.Now().UTC().UnixMilli())
return err
}
func (m *Manager) Get() Runtime {
m.mu.RLock()
defer m.mu.RUnlock()
return m.v
}
func (m *Manager) Refresh(ctx context.Context) error {
var raw string
if err := m.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key='runtime'`).Scan(&raw); err != nil {
return err
}
// Start from current defaults so databases created by older Neural Hunt
// versions automatically receive newly introduced settings.
var stored map[string]json.RawMessage
_ = json.Unmarshal([]byte(raw), &stored)
_, hadArtifactPreset := stored["artifact_preset"]
v := Defaults()
if err := json.Unmarshal([]byte(raw), &v); err != nil {
return err
}
// One-time upgrade for the bundled pre-collection database: older builds
// shipped the offline square SVG preset. The new collection is designed to
// work after only OPENAI_API_KEY + model are configured, so that exact legacy
// default is migrated to OpenAI portrait generation automatically. Explicit
// non-local provider choices are preserved.
if !hadArtifactPreset {
if strings.EqualFold(v.ArtifactProvider, "local") && v.ArtifactWidth == 1024 && v.ArtifactHeight == 1024 {
v.ArtifactPreset = "raccoon_full_art_v1"
v.ArtifactProvider = "openai"
v.ArtifactWidth = 1024
v.ArtifactHeight = 1536
v.ArtifactQuality = "medium"
} else {
// Existing deployments that deliberately chose another provider keep
// their old behavior until they explicitly opt into the collection preset.
v.ArtifactPreset = "legacy"
}
if b, err := json.Marshal(v); err == nil {
_, _ = m.db.ExecContext(ctx, `UPDATE settings SET value=?,updated_at=? WHERE key='runtime'`, string(b), time.Now().UTC().UnixMilli())
}
}
if err := Validate(v); err != nil {
return err
}
m.mu.Lock()
m.v = v
m.mu.Unlock()
return nil
}
func (m *Manager) Update(ctx context.Context, v Runtime) error {
if err := Validate(v); err != nil {
return err
}
b, _ := json.Marshal(v)
if _, err := m.db.ExecContext(ctx, `UPDATE settings SET value=?,updated_at=? WHERE key='runtime'`, string(b), time.Now().UTC().UnixMilli()); err != nil {
return err
}
m.mu.Lock()
m.v = v
m.mu.Unlock()
return nil
}
func Validate(v Runtime) error {
if v.GuessMinIntervalSec < 1 || v.GuessMinIntervalSec > 3600 {
return fmt.Errorf("guess_min_interval_sec must be 1..3600")
}
if v.ClientSubmitIntervalSec <= v.GuessMinIntervalSec || v.ClientSubmitIntervalSec > 7200 {
return fmt.Errorf("client_submit_interval_sec must be > guess_min_interval_sec and <= 7200")
}
if v.TaskRangeBits < 8 || v.TaskRangeBits > 128 {
return fmt.Errorf("task_range_bits must be 8..128")
}
if v.ActiveTaskCount < 1 || v.ActiveTaskCount > 64 {
return fmt.Errorf("active_task_count must be 1..64")
}
if v.PresenceTTLSec < 15 || v.PresenceTTLSec > 600 {
return fmt.Errorf("presence_ttl_sec must be 15..600")
}
if v.DefaultMaxNodes < 50 || v.DefaultMaxNodes > 100000 {
return fmt.Errorf("default_max_nodes must be 50..100000")
}
if v.PublicScorePrecision < 0 || v.PublicScorePrecision > 6 {
return fmt.Errorf("public_score_precision must be 0..6")
}
switch strings.ToLower(strings.TrimSpace(v.ArtifactPreset)) {
case "legacy", "raccoon_full_art_v1":
default:
return fmt.Errorf("artifact_preset must be legacy or raccoon_full_art_v1")
}
switch strings.ToLower(v.ArtifactProvider) {
case "local", "openai", "comfyui", "a1111", "auto":
default:
return fmt.Errorf("artifact_provider must be local, openai, comfyui, a1111 or auto")
}
if strings.TrimSpace(v.ArtifactModel) == "" {
return fmt.Errorf("artifact_model must not be empty")
}
if len(v.ArtifactPrompt) < 10 || len(v.ArtifactPrompt) > 12000 {
return fmt.Errorf("artifact_prompt length must be 10..12000")
}
if len(v.ArtifactNegativePrompt) > 8000 {
return fmt.Errorf("artifact_negative_prompt too long")
}
if v.ArtifactWidth < 256 || v.ArtifactWidth > 4096 || v.ArtifactHeight < 256 || v.ArtifactHeight > 4096 {
return fmt.Errorf("artifact dimensions must be 256..4096")
}
if v.ArtifactSteps < 1 || v.ArtifactSteps > 200 {
return fmt.Errorf("artifact_steps must be 1..200")
}
switch strings.ToLower(v.ArtifactQuality) {
case "low", "medium", "high", "auto":
default:
return fmt.Errorf("artifact_quality must be low, medium, high or auto")
}
return nil
}
func (m *Manager) Run(ctx context.Context) {
t := time.NewTicker(5 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
_ = m.Refresh(ctx)
}
}
}
+517
View File
@@ -0,0 +1,517 @@
'use strict';
const app = document.getElementById('app');
const $ = id => document.getElementById(id);
const esc = (s='') => String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const clamp = (v,a,b) => Math.max(a, Math.min(b, v));
const lerp = (a,b,t) => a + (b-a)*t;
const smooth = t => { t=clamp(t,0,1); return t*t*(3-2*t); };
const tokenKey = 'neuralhunt.token';
const adminTokenKey = 'neuralhunt.adminToken';
const mobileModeKey = 'neuralhunt.mobileMode';
const getToken = () => localStorage.getItem(tokenKey) || '';
function autoMobileMode(){return matchMedia('(max-width: 850px)').matches || matchMedia('(pointer: coarse)').matches}
function mobileModeEnabled(){const v=localStorage.getItem(mobileModeKey);return v===null?autoMobileMode():v==='1'}
function applyMobileMode(on,persist=false){document.documentElement.classList.toggle('mobile-mode',!!on);if(persist)localStorage.setItem(mobileModeKey,on?'1':'0');window.dispatchEvent(new CustomEvent('neuralhunt-mobile-mode',{detail:{enabled:!!on}}))}
function toggleMobileMode(){applyMobileMode(!document.documentElement.classList.contains('mobile-mode'),true)}
function mobileButtonLabel(){return document.documentElement.classList.contains('mobile-mode')?'MOBILE ON':'MOBILE OFF'}
const setToken = t => localStorage.setItem(tokenKey, t);
const clearToken = () => localStorage.removeItem(tokenKey);
async function api(path, init={}, admin=false) {
const token = admin ? localStorage.getItem(adminTokenKey) : getToken();
const headers = new Headers(init.headers || {});
if (!(init.body instanceof FormData)) headers.set('Content-Type','application/json');
if (token) headers.set('Authorization','Bearer '+token);
const r = await fetch(path,{...init,headers});
if (!r.ok) {
let msg = `HTTP ${r.status}`, payload = null;
try { payload=await r.json(); msg=payload?.error || msg; } catch {}
const err = new Error(msg); err.status = r.status; err.code = payload?.code || ''; err.data = payload || null; throw err;
}
return r.json();
}
async function loadProtectedImage(path,img,admin=false){
if(!img)return;const token=admin?localStorage.getItem(adminTokenKey):getToken();const headers={};if(token)headers.Authorization='Bearer '+token;
const r=await fetch(path,{headers});if(!r.ok)throw new Error(`Bild HTTP ${r.status}`);const blob=await r.blob();const old=img.dataset.objectUrl;if(old)URL.revokeObjectURL(old);const u=URL.createObjectURL(blob);img.dataset.objectUrl=u;img.src=u;
}
// Browser-persistent cryptographic identity. The private key never leaves the
// browser unencrypted. Export/import is password-protected AES-GCM.
const identityKey='neuralhunt.identity.v1';
const b64u=b=>{const a=b instanceof Uint8Array?b:new Uint8Array(b);let s='';a.forEach(x=>s+=String.fromCharCode(x));return btoa(s).replaceAll('+','-').replaceAll('/','_').replaceAll('=','')};
const ub64=s=>{s=s.replaceAll('-','+').replaceAll('_','/');while(s.length%4)s+='=';const x=atob(s);return Uint8Array.from(x,c=>c.charCodeAt(0))};
async function ensureIdentity(){const raw=localStorage.getItem(identityKey);if(raw)return JSON.parse(raw);const kp=await crypto.subtle.generateKey({name:'ECDSA',namedCurve:'P-256'},true,['sign','verify']);const b={version:1,publicJwk:await crypto.subtle.exportKey('jwk',kp.publicKey),privateJwk:await crypto.subtle.exportKey('jwk',kp.privateKey)};localStorage.setItem(identityKey,JSON.stringify(b));return b}
async function clientId(pub){const s=`${pub.kty}|${pub.crv}|${pub.x}|${pub.y}`;return b64u(await crypto.subtle.digest('SHA-256',new TextEncoder().encode(s)))}
async function sign(message){const b=await ensureIdentity();const k=await crypto.subtle.importKey('jwk',b.privateJwk,{name:'ECDSA',namedCurve:'P-256'},false,['sign']);return b64u(await crypto.subtle.sign({name:'ECDSA',hash:'SHA-256'},k,new TextEncoder().encode(message)))}
async function responseError(r,fallback){try{const b=await r.json();return b?.error?`${fallback}: ${b.error}`:`${fallback} (HTTP ${r.status})`}catch{return `${fallback} (HTTP ${r.status})`}}
async function loginIdentity(){const b=await ensureIdentity();const cr=await fetch('/api/auth/challenge',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({public_jwk:b.publicJwk})});if(!cr.ok)throw new Error(await responseError(cr,'Challenge fehlgeschlagen'));const c=await cr.json();const signature=await sign(`login|${c.challenge}|${c.client_id}`);const r=await fetch('/api/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({public_jwk:b.publicJwk,challenge:c.challenge,signature})});if(!r.ok)throw new Error(await responseError(r,'Login fehlgeschlagen'));return r.json()}
async function deterministicGuess(taskID,seed,cid,seq,bits){const h=new Uint8Array(await crypto.subtle.digest('SHA-256',new TextEncoder().encode(`${taskID}|${seed}|${cid}|${seq}`)));let n=0n;for(const x of h)n=(n<<8n)|BigInt(x);return (n%(1n<<BigInt(bits))).toString()}
async function exportIdentity(passphrase){const b=await ensureIdentity();const salt=crypto.getRandomValues(new Uint8Array(16));const iv=crypto.getRandomValues(new Uint8Array(12));const base=await crypto.subtle.importKey('raw',new TextEncoder().encode(passphrase),'PBKDF2',false,['deriveKey']);const aes=await crypto.subtle.deriveKey({name:'PBKDF2',salt,iterations:250000,hash:'SHA-256'},base,{name:'AES-GCM',length:256},false,['encrypt']);const ct=await crypto.subtle.encrypt({name:'AES-GCM',iv},aes,new TextEncoder().encode(JSON.stringify(b)));return JSON.stringify({version:1,salt:b64u(salt),iv:b64u(iv),ciphertext:b64u(ct)},null,2)}
async function importIdentity(raw,passphrase){const x=JSON.parse(raw);const base=await crypto.subtle.importKey('raw',new TextEncoder().encode(passphrase),'PBKDF2',false,['deriveKey']);const aes=await crypto.subtle.deriveKey({name:'PBKDF2',salt:ub64(x.salt),iterations:250000,hash:'SHA-256'},base,{name:'AES-GCM',length:256},false,['decrypt']);const pt=await crypto.subtle.decrypt({name:'AES-GCM',iv:ub64(x.iv)},aes,ub64(x.ciphertext));const b=JSON.parse(new TextDecoder().decode(pt));localStorage.setItem(identityKey,JSON.stringify(b));return b}
function hashInt(s){let h=2166136261>>>0;s=String(s||'');for(let i=0;i<s.length;i++){h^=s.charCodeAt(i);h=Math.imul(h,16777619)}return h>>>0}
function pseudo(s,o=0){return ((Math.sin((hashInt(`${s}:${o}`)+1)*0.00000137+o*12.345)*43758.5453123)%1+1)%1}
function scoreRGB(score){
const t=clamp(Number(score||0)/100,0,1);
// low = deep blue, middle = cyan, high = mint/amber, near-perfect = magenta-white
const stops=[[0,[75,123,255]],[.45,[82,231,255]],[.78,[93,255,189]],[.94,[255,180,82]],[1,[255,111,188]]];
for(let i=1;i<stops.length;i++) if(t<=stops[i][0]){const [a,ca]=stops[i-1],[b,cb]=stops[i];const q=(t-a)/(b-a);return ca.map((v,j)=>Math.round(lerp(v,cb[j],q)))}
return stops.at(-1)[1];
}
function rgba(rgb,a){return `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${a})`}
function makeLOD(points,maxNodes,selfId,enabled=true){
const source=(points||[]).map(p=>({...p,_count:1,_group:false,_bestScore:Number(p.score||0)}));
if(source.length<=maxNodes) return source;
const self=source.find(p=>p.client_id===selfId);
if(!enabled){
const rest=self?source.filter(p=>p!==self):source;
rest.sort((a,b)=>(Number(b.score||0)-Number(a.score||0))||(Number(a.rank||1e12)-Number(b.rank||1e12))||a.client_id.localeCompare(b.client_id));
const keep=rest.slice(0,Math.max(1,maxNodes-(self?1:0)));
return self?[...keep,self]:keep;
}
const others=self?source.filter(p=>p!==self):source;
const target=Math.max(8,maxNodes-(self?1:0));
if(others.length<=target) return self?[...others,self]:others;
let minX=Infinity,maxX=-Infinity,minY=Infinity,maxY=-Infinity,minZ=Infinity,maxZ=-Infinity;
for(const p of others){minX=Math.min(minX,p.x);maxX=Math.max(maxX,p.x);minY=Math.min(minY,p.y);maxY=Math.max(maxY,p.y);minZ=Math.min(minZ,p.z);maxZ=Math.max(maxZ,p.z)}
const span=Math.max(1,maxX-minX,maxY-minY,maxZ-minZ);
let cell=span/Math.max(2,Math.cbrt(target)*.82), grouped=[];
for(let attempt=0;attempt<12;attempt++){
const buckets=new Map();
for(const p of others){
const ix=Math.floor((p.x-minX)/cell),iy=Math.floor((p.y-minY)/cell),iz=Math.floor((p.z-minZ)/cell),key=`${ix}:${iy}:${iz}`;
let b=buckets.get(key);
if(!b){b={client_id:`cluster:${key}`,x:0,y:0,z:0,score:0,rank:Number.MAX_SAFE_INTEGER,guess_count:0,_count:0,_group:true,_bestScore:0,_scoreSum:0};buckets.set(key,b)}
const weight=1+Math.sqrt(Math.max(0,Number(p.score||0)))/12;
b.x+=Number(p.x)*weight;b.y+=Number(p.y)*weight;b.z+=Number(p.z)*weight;b._mass=(b._mass||0)+weight;
b._count++;b._scoreSum+=Number(p.score||0);b._bestScore=Math.max(b._bestScore,Number(p.score||0));b.rank=Math.min(b.rank,Number(p.rank||Number.MAX_SAFE_INTEGER));b.guess_count+=Number(p.guess_count||0);
}
grouped=[...buckets.values()].map(b=>({...b,x:b.x/b._mass,y:b.y/b._mass,z:b.z/b._mass,score:b._scoreSum/b._count}));
if(grouped.length<=target) break;
cell*=1.24;
}
if(grouped.length>target){
grouped.sort((a,b)=>(b._bestScore-a._bestScore)||(b._count-a._count)||a.client_id.localeCompare(b.client_id));
grouped=grouped.slice(0,target);
}
return self?[...grouped,self]:grouped;
}
class NeuralMap {
constructor(host, opts={}) {
this.host=host;
this.opts={panelOffset:0,admin:false,...opts};
this.canvas=document.createElement('canvas'); this.canvas.className='brain-canvas'; host.appendChild(this.canvas);
this.ctx=this.canvas.getContext('2d',{alpha:false});
this.tooltip=document.createElement('div'); this.tooltip.className='tooltip hidden'; host.appendChild(this.tooltip);
this.points=[]; this.rawPoints=[]; this.selfId=''; this.maxNodes=2000; this.lodEnabled=true;
// TARGET FIELD is the default: score controls orbital radius exactly. RAW 3D
// remains available for inspecting the server-provided x/y/z coordinates.
this.yaw=.18; this.pitch=-.58; this.zoom=1.02; this.autoRotate=true; this.labels=true; this.edges=true; this.shells=true; this.eco=false;
this.proximityFocus=true;
this.drag=false; this.moved=false; this.lastX=0; this.lastY=0; this.mouseX=0; this.mouseY=0; this.hover=null;
this.width=1; this.height=1; this.dpr=1; this.last=performance.now(); this.lastPaint=0; this.fps=0; this.fpsFrames=0; this.fpsStart=performance.now();
this.renderCount=0; this.background=document.createElement('canvas'); this.backgroundKey='';
this.motion=new Map(); this.scoreMotion=new Map(); this.particles=[]; this.projected=[]; this.statsAt=0;
this.ro=new ResizeObserver(()=>this.resize()); this.ro.observe(host); this.resize(); this.bind();
this.frame=requestAnimationFrame(t=>this.draw(t));
}
bind(){
this.canvas.addEventListener('pointerdown',e=>{this.drag=true;this.moved=false;this.lastX=e.clientX;this.lastY=e.clientY;this.canvas.setPointerCapture(e.pointerId)});
this.canvas.addEventListener('pointermove',e=>{const r=this.canvas.getBoundingClientRect();this.mouseX=e.clientX-r.left;this.mouseY=e.clientY-r.top;if(!this.drag){this.pick();return}const dx=e.clientX-this.lastX,dy=e.clientY-this.lastY;if(Math.abs(dx)+Math.abs(dy)>2)this.moved=true;this.yaw+=dx*.006;this.pitch=clamp(this.pitch+dy*.004,-1.02,-.12);this.lastX=e.clientX;this.lastY=e.clientY});
this.canvas.addEventListener('pointerup',()=>{this.drag=false;this.pick()});
this.canvas.addEventListener('pointercancel',()=>this.drag=false);
this.canvas.addEventListener('pointerleave',()=>{if(!this.drag){this.hover=null;this.tooltip.classList.add('hidden')}});
this.canvas.addEventListener('wheel',e=>{e.preventDefault();this.zoom=clamp(this.zoom*Math.exp(-e.deltaY*.001),.55,2.4)},{passive:false});
this.canvas.addEventListener('dblclick',()=>this.resetView());
}
resize(){const r=this.host.getBoundingClientRect();this.width=Math.max(1,r.width);this.height=Math.max(1,r.height);this.dpr=Math.min(devicePixelRatio||1,this.eco?1.15:2);this.canvas.width=Math.max(1,Math.floor(this.width*this.dpr));this.canvas.height=Math.max(1,Math.floor(this.height*this.dpr));this.canvas.style.width=this.width+'px';this.canvas.style.height=this.height+'px';this.backgroundKey=''}
setOptions(opts={}){Object.assign(this,opts);if('eco' in opts)this.resize();if('lodEnabled' in opts||'maxNodes' in opts)this.rebuild()}
resetView(){this.yaw=.18;this.pitch=-.58;this.zoom=1.02}
fieldRadius(score){
// Score itself is logarithmic. This perceptual expansion deliberately gives
// the 90..100 region much more room so 95, 98 and 99+ are visibly distinct.
const miss=clamp(1-Number(score||0)/100,0,1);
return .045+.955*Math.pow(miss,.38);
}
fieldGeometry(){
const panel=this.width>1050?this.opts.panelOffset:0;
const scale=Math.min(this.width*(this.opts.admin?.36:.34),this.height*.42)*this.zoom;
// TARGET FIELD stays face-on so screen distance is a mathematically exact
// proximity cue. Pitch changes only depth/perspective intensity.
const depth=clamp(.07+(-this.pitch-.12)*.10,.07,.16);
return {cx:this.width/2+panel,cy:this.height/2+8,scale,depth};
}
currentMotion(id,now=performance.now()){const m=this.motion.get(id);if(!m)return null;const q=smooth((now-m.start)/m.duration);return {x:lerp(m.fx,m.tx,q),y:lerp(m.fy,m.ty,q),z:lerp(m.fz,m.tz,q)}}
currentScore(p,now=performance.now()){
const m=this.scoreMotion.get(p.client_id); if(!m)return Number(p._group?(p._bestScore||p.score):p.score||0);
const q=clamp((now-m.start)/m.duration,0,1); if(q>=1){this.scoreMotion.delete(p.client_id);return Number(m.to)}
return lerp(m.from,m.to,smooth(q));
}
update(points,selfId,maxNodes){
const oldRaw=new Map(this.rawPoints.map(p=>[p.client_id,p])),now=performance.now();
this.rawPoints=Array.isArray(points)?points:[]; this.selfId=selfId||''; this.maxNodes=Math.max(10,Number(maxNodes||this.maxNodes));
for(const p of this.rawPoints){const prev=oldRaw.get(p.client_id);if(prev&&Number(p.score||0)>Number(prev.score||0)+.0001){this.scoreMotion.set(p.client_id,{from:Number(prev.score||0),to:Number(p.score||0),start:now,duration:900});this.emitSignal(p)}}
this.rebuild();
}
rebuild(){
const now=performance.now(),next=makeLOD(this.rawPoints,this.maxNodes,this.selfId,this.lodEnabled),oldBy=new Map(this.points.map(p=>[p.client_id,p]));
for(const p of next){const prior=this.currentMotion(p.client_id,now)||oldBy.get(p.client_id)||p;this.motion.set(p.client_id,{fx:Number(prior.x||0),fy:Number(prior.y||0),fz:Number(prior.z||0),tx:Number(p.x||0),ty:Number(p.y||0),tz:Number(p.z||0),start:now,duration:650})}
this.points=next;
}
emitSignal(p){
if(this.particles.length>180)this.particles.splice(0,this.particles.length-120);
const now=performance.now(),col=scoreRGB(p.score);
for(let i=0;i<3;i++)this.particles.push({id:p.client_id,start:now+i*95,duration:720+i*90,color:col,size:1+clamp(Number(p.score||0)/100,0,1)*.55});
}
rawCamera(){const panel=this.width>1050?this.opts.panelOffset:0;return {cy:Math.cos(this.yaw),sy:Math.sin(this.yaw),cp:Math.cos(this.pitch),sp:Math.sin(this.pitch),cx:this.width/2+panel,cyy:this.height/2,scale:Math.min(this.width*(this.opts.admin?.44:.40),this.height*.48)*this.zoom}}
projectRawXYZ(x,y,z){const cam=this.rawCamera();const x1=x*cam.cy-z*cam.sy,z1=x*cam.sy+z*cam.cy,y1=y*cam.cp-z1*cam.sp,z2=y*cam.sp+z1*cam.cp;const perspective=2.9/(3.3-z2*.042);return {x:cam.cx+x1*cam.scale*perspective/13.5,y:cam.cyy-y1*cam.scale*perspective/13.5,z:z2,p:perspective}}
projectXYZ(x,y,z){return this.projectRawXYZ(x,y,z)}
projectFieldPoint(p,now){
const g=this.fieldGeometry(),score=this.currentScore(p,now),r=this.fieldRadius(score);
const id=p.client_id||'node',a=pseudo(id,41)*Math.PI*2+this.yaw;
const side=Math.cos(a)*r,vertical=Math.sin(a)*r;
// Screen radius is exactly r*scale. The third dimension is encoded only as
// perspective/brightness, never as a positional offset that could invert
// who appears closer to the task.
const z=(Math.sin(a)*.78+(pseudo(id,42)-.5)*.22)*r;
const x=g.cx+side*g.scale;
const y=g.cy+vertical*g.scale;
const perspective=clamp(1+z*g.depth,.90,1.10);
return {x,y,z,p:perspective,score,fieldR:r,angle:a};
}
projectPoint(p,now){
if(this.proximityFocus)return this.projectFieldPoint(p,now);
const m=this.currentMotion(p.client_id,now)||p;return this.projectRawXYZ(Number(m.x||0),Number(m.y||0),Number(m.z||0));
}
buildBackground(){const key=`${Math.floor(this.width)}:${Math.floor(this.height)}:${this.eco?1:0}`;if(key===this.backgroundKey)return;this.backgroundKey=key;this.background.width=Math.ceil(this.width);this.background.height=Math.ceil(this.height);const c=this.background.getContext('2d',{alpha:false}),g=c.createRadialGradient(this.width*.5,this.height*.48,30,this.width*.5,this.height*.48,Math.max(this.width,this.height)*.78);g.addColorStop(0,'#071827');g.addColorStop(.5,'#020711');g.addColorStop(1,'#010207');c.fillStyle=g;c.fillRect(0,0,this.width,this.height);c.globalAlpha=this.eco?.08:.14;const stars=this.eco?28:90;for(let i=0;i<stars;i++){const x=pseudo('star',i*2)*this.width,y=pseudo('star',i*2+1)*this.height;c.fillStyle=i%11===0?'#52e7ff':'#5d7890';const s=i%13===0?1.4:.7;c.fillRect(x,y,s,s)}c.globalAlpha=1}
drawBackground(now){this.buildBackground();const c=this.ctx;c.drawImage(this.background,0,0,this.width,this.height);c.save();c.globalAlpha=this.eco?.04:.08;for(let i=0;i<(this.eco?6:16);i++){const x=(pseudo(`moving:${i}`,1)*this.width+now*.002*(i%3+1))%this.width,y=pseudo(`moving:${i}`,2)*this.height;c.fillStyle=i%7===0?'#52e7ff':'#6a8194';c.fillRect(x,y,i%9===0?1.2:.65,i%9===0?1.2:.65)}c.restore()}
drawRawRing3D(radius,plane,col,alpha,width,dash){const c=this.ctx;c.strokeStyle=rgba(col,alpha);c.lineWidth=width;c.setLineDash(dash||[]);c.beginPath();const steps=this.eco?32:64;for(let i=0;i<=steps;i++){const a=i/steps*Math.PI*2,co=Math.cos(a)*radius,si=Math.sin(a)*radius;let q;if(plane==='xy')q=this.projectRawXYZ(co,si,0);else if(plane==='xz')q=this.projectRawXYZ(co,0,si);else q=this.projectRawXYZ(0,co,si);if(i===0)c.moveTo(q.x,q.y);else c.lineTo(q.x,q.y)}c.stroke();c.setLineDash([])}
drawTargetField(){
if(!this.shells||!this.proximityFocus)return;
const c=this.ctx,g=this.fieldGeometry(),bands=[0,25,50,75,90,95,99];
c.save();c.globalCompositeOperation='screen';
// radial guide spokes: orientation only, never data relationships
c.strokeStyle='rgba(87,135,160,.065)';c.lineWidth=.5;
for(let i=0;i<12;i++){const a=i/12*Math.PI*2+this.yaw,rx=Math.cos(a)*g.scale,ry=Math.sin(a)*g.scale;c.beginPath();c.moveTo(g.cx,g.cy);c.lineTo(g.cx+rx,g.cy+ry);c.stroke()}
for(const score of bands){
const r=this.fieldRadius(score),rx=g.scale*r,col=scoreRGB(score),major=score>=90;
// Back half is dim/dashed; front half is brighter. This depth cue makes
// the target plane read as 3D while ring radius stays a precise score cue.
c.setLineDash(major?[3,6]:[2,9]);c.strokeStyle=rgba(col,major?.14:.06);c.lineWidth=major?.9:.55;c.beginPath();c.arc(g.cx,g.cy,rx,Math.PI,Math.PI*2);c.stroke();
c.setLineDash([]);c.strokeStyle=rgba(col,major?.38:.14);c.lineWidth=major?1.05:.65;c.beginPath();c.arc(g.cx,g.cy,rx,0,Math.PI);c.stroke();
if(this.labels&&this.width>650){const lx=g.cx+rx+5,ly=g.cy;c.font=major?'800 9px Inter,system-ui':'700 8px Inter,system-ui';c.fillStyle=rgba(col,major?.82:.47);c.fillText(score===99?'99+':String(score),lx,ly+3)}
}
if(this.labels&&this.width>720){c.font='700 8px Inter,system-ui';c.fillStyle='rgba(134,171,190,.58)';c.fillText('AUSSEN = WEIT',g.cx-g.scale-2,g.cy+g.scale+19);c.fillText('INNEN = NAH AM TASK',g.cx+14,g.cy-18)}
c.restore();
}
drawRawShells(){if(!this.shells||this.proximityFocus)return;const bands=[0,25,50,75,90,95,99];const c=this.ctx;c.save();c.globalCompositeOperation='screen';for(const s of bands){const r=.34+13*Math.sqrt(clamp(1-s/100,0,1)),col=scoreRGB(s);this.drawRawRing3D(r,'xy',col,s>=95?.19:.06,s>=95?1:.6,[3,7])}c.restore()}
drawAura(now){const c=this.ctx,g=this.proximityFocus?this.fieldGeometry():null,core=g?{x:g.cx,y:g.cy}:this.projectRawXYZ(0,0,0),pulse=.5+.5*Math.sin(now*.0032),radius=this.eco?52:74+pulse*9;c.save();c.globalCompositeOperation='screen';const grad=c.createRadialGradient(core.x,core.y,0,core.x,core.y,radius);grad.addColorStop(0,'rgba(82,231,255,.17)');grad.addColorStop(.25,'rgba(82,231,255,.045)');grad.addColorStop(1,'rgba(82,231,255,0)');c.fillStyle=grad;c.beginPath();c.arc(core.x,core.y,radius,0,Math.PI*2);c.fill();c.restore()}
drawClouds(now){
if(this.eco||this.points.length<6||!this.proximityFocus)return;
const c=this.ctx,g=this.fieldGeometry(),groups=[{lo:0,hi:50},{lo:50,hi:75},{lo:75,hi:90},{lo:90,hi:95},{lo:95,hi:101}];
c.save();c.globalCompositeOperation='screen';
for(const band of groups){const members=this.points.filter(p=>{const s=Number(p._group?(p._bestScore||p.score):p.score||0);return s>=band.lo&&s<band.hi});if(!members.length)continue;const mid=(band.lo+band.hi)/2,r=this.fieldRadius(mid),col=scoreRGB(mid),alpha=band.lo>=90?.018:.008;c.strokeStyle=rgba(col,alpha*Math.min(4,1+Math.log10(members.length+1)));c.lineWidth=Math.max(8,g.scale*(band.hi-band.lo)/900);c.beginPath();c.arc(g.cx,g.cy,g.scale*r,0,Math.PI*2);c.stroke()}c.restore()
}
selectedPaths(projected){
const singles=projected.filter(x=>!x.p._group).slice().sort((a,b)=>Number((b.q.score??b.p.score)||0)-Number((a.q.score??a.p.score)||0));
const chosen=singles.filter(x=>Number((x.q.score??x.p.score)||0)>=75).slice(0,this.eco?4:9);
const self=singles.find(x=>x.p.client_id===this.selfId);if(self&&!chosen.includes(self))chosen.push(self);
return chosen;
}
pathControl(q,core,id){const dx=core.x-q.x,dy=core.y-q.y,len=Math.max(1,Math.hypot(dx,dy)),nx=-dy/len,ny=dx/len,bend=(pseudo(id,73)-.5)*Math.min(46,len*.18);return {x:(q.x+core.x)/2+nx*bend,y:(q.y+core.y)/2+ny*bend}}
drawEdges(projected){
// "Signalwege" are intentionally NOT client-to-client graph edges. There
// is no semantic client relation in Neural Hunt. Only contenders -> task
// paths are drawn, so every visible line has a real meaning.
if(!this.edges||!projected.length)return;
const c=this.ctx,core=this.proximityFocus?(()=>{const g=this.fieldGeometry();return{x:g.cx,y:g.cy}})():this.projectRawXYZ(0,0,0),chosen=this.selectedPaths(projected);
c.save();c.globalCompositeOperation='screen';
chosen.forEach((x,i)=>{const score=Number((x.q.score??x.p.score)||0),self=x.p.client_id===this.selfId,col=self?[255,255,255]:scoreRGB(score),ctrl=this.pathControl(x.q,core,x.p.client_id),alpha=self?.36:i<3?.20:.07+.08*clamp((score-75)/25,0,1);c.strokeStyle=rgba(col,alpha);c.lineWidth=self?1.35:i<3?.9:.55;c.setLineDash(score<90?[2,7]:[]);c.beginPath();c.moveTo(x.q.x,x.q.y);c.quadraticCurveTo(ctrl.x,ctrl.y,core.x,core.y);c.stroke()});
c.setLineDash([]);c.restore();
}
drawParticles(now){
if(!this.particles.length)return;const c=this.ctx,by=new Map(this.projected.map(x=>[x.p.client_id,x])),core=this.proximityFocus?(()=>{const g=this.fieldGeometry();return{x:g.cx,y:g.cy}})():this.projectRawXYZ(0,0,0);
c.save();c.globalCompositeOperation='lighter';
for(let i=this.particles.length-1;i>=0;i--){const x=this.particles[i],t=(now-x.start)/x.duration;if(t<0)continue;if(t>=1){this.particles.splice(i,1);continue}const px=by.get(x.id);if(!px)continue;const q=px.q,ctrl=this.pathControl(q,core,x.id),e=smooth(t),u=1-e,bx=u*u*q.x+2*u*e*ctrl.x+e*e*core.x,byy=u*u*q.y+2*u*e*ctrl.y+e*e*core.y,r=(this.eco?2.0:5.0)*x.size*(.8+Math.sin(t*Math.PI)*.35);if(this.eco){c.fillStyle=rgba(x.color,.68);c.beginPath();c.arc(bx,byy,Math.max(1,r),0,Math.PI*2);c.fill()}else{const grad=c.createRadialGradient(bx,byy,0,bx,byy,r);grad.addColorStop(0,'rgba(255,255,255,.98)');grad.addColorStop(.2,rgba(x.color,.85));grad.addColorStop(1,rgba(x.color,0));c.fillStyle=grad;c.beginPath();c.arc(bx,byy,r,0,Math.PI*2);c.fill()}}
c.restore();
}
drawNodes(projected,now){
const c=this.ctx,sorted=[...projected].filter(x=>!x.p._group).sort((a,b)=>Number((b.q.score??b.p.score)||0)-Number((a.q.score??a.p.score)||0)),topIDs=new Set(sorted.slice(0,10).map(x=>x.p.client_id));
c.save();c.globalCompositeOperation='lighter';
for(const {p,q} of projected){const self=p.client_id===this.selfId,group=p._group||p._count>1,score=Number((q.score??(group?(p._bestScore||p.score):p.score))||0),col=self?[255,255,255]:scoreRGB(score),depth=this.proximityFocus?clamp(q.p,.88,1.12):clamp(.74+q.p*.18,.72,1.22),mass=group?Math.min(7,1+Math.log2((p._count||1)+1)*.82):0,near=clamp((score-75)/25,0,1),base=self?5.6:group?3.4+mass:1.15+clamp(score/75,0,1)*.55+near*2.25,breathe=.5+.5*Math.sin(now*.0016+hashInt(p.client_id)*.00001),r=Math.max(.9,base*depth*(.94+breathe*.08)),important=self||score>=90||group||topIDs.has(p.client_id);
if(important&&!this.eco){const hr=r*(self?5.0:group?3.0:3.1+near*1.2),grad=c.createRadialGradient(q.x,q.y,0,q.x,q.y,hr);grad.addColorStop(0,rgba(col,self?.62:.20+near*.12));grad.addColorStop(.3,rgba(col,self?.15:.05+near*.06));grad.addColorStop(1,rgba(col,0));c.fillStyle=grad;c.beginPath();c.arc(q.x,q.y,hr,0,Math.PI*2);c.fill()}
c.fillStyle=rgba(col,self?.99:group?.70:.10+.17*clamp(score/75,0,1)+near*.62);c.beginPath();c.arc(q.x,q.y,r,0,Math.PI*2);c.fill();
if(score>=95&&!group){c.strokeStyle=rgba(col,.35+near*.35);c.lineWidth=.7;c.beginPath();c.arc(q.x,q.y,r*(1.75+near*.55)+breathe,0,Math.PI*2);c.stroke()}
if(group){c.strokeStyle=rgba(col,.40);c.lineWidth=.75;c.beginPath();c.arc(q.x,q.y,r*1.34+breathe,0,Math.PI*2);c.stroke();if(!this.eco&&p._count>=5&&r>4.2){c.save();c.globalCompositeOperation='source-over';c.font='700 8px Inter,system-ui';c.textAlign='center';c.textBaseline='middle';c.fillStyle='rgba(236,249,255,.88)';c.fillText(p._count>999?`${Math.round(p._count/100)/10}k`:String(p._count),q.x,q.y+.4);c.restore()}}
if(self){c.strokeStyle='rgba(255,255,255,.96)';c.lineWidth=1.15;c.beginPath();c.arc(q.x,q.y,r*2.2+breathe*1.7,0,Math.PI*2);c.stroke()}
}
c.restore();
if(this.labels){c.save();c.globalCompositeOperation='source-over';c.font='10px Inter,system-ui';c.textBaseline='middle';let n=0,max=this.eco?8:24,occupied=[];for(const {p,q} of [...projected].sort((a,b)=>Number((b.q.score??b.p.score)||0)-Number((a.q.score??a.p.score)||0))){if(n>=max)break;const self=p.client_id===this.selfId,group=p._group||p._count>1,score=Number((q.score??p.score)||0),important=self||topIDs.has(p.client_id)||score>=95||this.hover?.p?.client_id===p.client_id;if(!important)continue;const text=self?`DU · ${score.toFixed(2)}`:group?`${p._count} Clients · best ${Number(p._bestScore||0).toFixed(1)}`:`#${p.rank||'—'} · ${score.toFixed(2)}`,w=c.measureText(text).width+14,x=clamp(q.x+10,4,this.width-w-4),y=clamp(q.y-9,4,this.height-22);if(!self&&occupied.some(b=>Math.abs(b.x-x)<(b.w+w)*.48&&Math.abs(b.y-y)<17))continue;occupied.push({x,y,w});c.fillStyle='rgba(2,7,14,.88)';c.fillRect(x,y,w,18);c.strokeStyle=rgba(self?[255,255,255]:scoreRGB(score),self?.55:.16);c.lineWidth=.5;c.strokeRect(x,y,w,18);c.fillStyle=rgba(self?[255,255,255]:scoreRGB(score),.98);c.fillText(text,x+7,y+9);n++}c.restore()}
}
drawCore(now){const c=this.ctx,g=this.proximityFocus?this.fieldGeometry():null,q=g?{x:g.cx,y:g.cy}:this.projectRawXYZ(0,0,0),pulse=.5+.5*Math.sin(now*.004);c.save();c.globalCompositeOperation='lighter';const radius=this.eco?30:48+pulse*7;if(!this.eco){const grad=c.createRadialGradient(q.x,q.y,0,q.x,q.y,radius);grad.addColorStop(0,'rgba(255,255,255,.99)');grad.addColorStop(.10,'rgba(99,243,255,.92)');grad.addColorStop(.44,'rgba(82,231,255,.13)');grad.addColorStop(1,'rgba(82,231,255,0)');c.fillStyle=grad;c.beginPath();c.arc(q.x,q.y,radius,0,Math.PI*2);c.fill()}c.fillStyle='#fff';c.beginPath();c.arc(q.x,q.y,5.7+pulse*.8,0,Math.PI*2);c.fill();c.strokeStyle=`rgba(82,231,255,${.54+pulse*.28})`;c.lineWidth=1;c.beginPath();c.arc(q.x,q.y,13+pulse*3,0,Math.PI*2);c.stroke();c.restore();if(this.labels&&this.width>610){c.save();c.font='800 10px Inter,system-ui';c.fillStyle='rgba(224,251,255,.92)';c.fillText('TASK · 100',q.x+20,q.y+3);c.restore()}}
pick(){if(!this.projected.length)return;let best=null,bestD=20;for(const x of this.projected){const d=Math.hypot(x.q.x-this.mouseX,x.q.y-this.mouseY);if(d<bestD){best=x;bestD=d}}this.hover=best;if(!best){this.tooltip.classList.add('hidden');return}const p=best.p,group=p._group||p._count>1,score=Number((best.q.score??p.score)||0),zone=score>=99?'99+ · unmittelbar am Task':score>=95?'9599 · sehr nah':score>=90?'9095 · nah':score>=75?'7590 · gutes Feld':score>=50?'5075 · mittlere Distanz':'<50 · weit';this.tooltip.innerHTML=group?`<strong>LOD-Gruppe · ${p._count} Clients</strong><small>Ø Score ${Number(p.score||0).toFixed(2)} · Best ${Number(p._bestScore||0).toFixed(2)}<br>${Number(p.guess_count||0).toLocaleString('de-DE')} Tipps</small>`:`<strong>${p.client_id===this.selfId?'DU':esc(String(p.client_id).slice(0,16))}</strong><small>Score ${score.toFixed(2)} · Rank #${p.rank||'—'}<br>${zone}<br>${Number(p.guess_count||0).toLocaleString('de-DE')} Tipps</small>`;this.tooltip.classList.remove('hidden');this.tooltip.style.left=clamp(this.mouseX+14,8,this.width-270)+'px';this.tooltip.style.top=clamp(this.mouseY+14,8,this.height-100)+'px'}
draw(now){const minFrame=this.eco?33:0;if(minFrame&&this.lastPaint&&now-this.lastPaint<minFrame){this.frame=requestAnimationFrame(t=>this.draw(t));return}this.lastPaint=now;const dt=Math.min(.075,(now-this.last)/1000);this.last=now;if(this.autoRotate&&!this.drag)this.yaw+=dt*.035;this.fpsFrames++;if(now-this.fpsStart>800){this.fps=Math.round(this.fpsFrames*1000/(now-this.fpsStart));this.fpsFrames=0;this.fpsStart=now}
const c=this.ctx;c.setTransform(this.dpr,0,0,this.dpr,0,0);this.drawBackground(now);this.drawAura(now);this.drawTargetField();this.drawRawShells();this.drawClouds(now);
const projected=[];for(const p of this.points)projected.push({p,q:this.projectPoint(p,now)});projected.sort((a,b)=>a.q.z-b.q.z);this.projected=projected;this.renderCount=projected.length;this.drawEdges(projected);this.drawParticles(now);this.drawNodes(projected,now);this.drawCore(now);if(this.hover)this.pick();
if(this.opts.onStats&&now>this.statsAt){this.statsAt=now+500;this.opts.onStats({fps:this.fps,render:this.renderCount,raw:this.rawPoints.length,groups:this.points.filter(p=>p._group).length})}
this.frame=requestAnimationFrame(t=>this.draw(t));
}
destroy(){cancelAnimationFrame(this.frame);this.ro.disconnect();this.canvas.remove();this.tooltip.remove()}
}
function markHTML(){return '<span class="mark"><i></i><i></i><i></i></span>'}
function setActive(id,on){const el=$(id);if(el)el.classList.toggle('active',!!on)}
function shortID(s,n=10){return String(s||'').slice(0,n)}
function fmtDate(v){try{return new Date(v).toLocaleString('de-DE')}catch{return '—'}}
function fmtScore(v){return Number(v||0).toFixed(2)}
function actionLabel(a){return ({set_range_bits:'Zahlenraum ändern',set_intervals:'Intervalle setzen',clear_intervals:'Intervalle erben',pause:'Task pausieren',resume:'Task fortsetzen',reroll:'Ziel neu würfeln',close:'Task beenden',regenerate_artifact:'NFT-Bild neu erzeugen'})[a]||a}
function proximityRows(points,selfId,limit=7){
const rows=(Array.isArray(points)?points:[]).filter(p=>!p._group).slice().sort((a,b)=>Number(b.score||0)-Number(a.score||0)).slice(0,limit);
const self=(points||[]).find(p=>p.client_id===selfId);if(self&&!rows.some(x=>x.client_id===selfId))rows.push(self);
return rows;
}
function userShell(){
app.className='hunt';
app.innerHTML=`
<div class="stage"><div id="map" class="neural-map"></div><div class="vignette"></div></div>
<header class="topbar glass">
<div class="brand">${markHTML()}<div><strong>NEURAL HUNT</strong><small>Social Probability Experiment · signed clients</small></div></div>
<div class="mode-status living" id="visualMode"><i></i><div><b id="modeTitle">LIVING</b><small id="modeDetail">Task auswählen</small></div></div>
<div class="mobile-quick"><span>RANK <b id="rankMobile">#—</b></span><span>SCORE <b id="scoreMobile">0.00</b></span><span id="guessMobile">—</span></div>
<div class="metrics"><span><b id="nodecount">0</b> Clients</span><span><b id="rendercount">0</b> Render</span><span><b id="fpscount">0</b> FPS</span><span>Task <b id="taskid">—</b></span><span class="state" id="systemState"><i></i><span id="status">initialisiert</span></span></div>
</header>
<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="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">Export</button><label class="button">Import<input id="importid" hidden type="file" accept="application/json"></label></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><a class="dock-link" href="/admin">ADMIN</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><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>
</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;
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()};
const status=(s,mode='living')=>{if($('status'))$('status').textContent=s;const m=$('visualMode');if(m){m.className=`mode-status ${mode}`;$('modeTitle').textContent=mode==='thinking'?'GUESS':mode==='researching'?'WIN':task?.paused?'PAUSED':'LIVING';$('modeDetail').textContent=s}};
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 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()}
function clearWSReconnect(){if(wsReconnectTimer){clearTimeout(wsReconnectTimer);wsReconnectTimer=null}}
function wsReady(){return !!ws&&ws.readyState===WebSocket.OPEN}
async function closeWS(){clearWSReconnect();if(!ws)return;const socket=ws;ws=null;socket._plannedClose=true;await new Promise(resolve=>{let done=false;const finish=()=>{if(done)return;done=true;resolve()};socket.addEventListener('close',finish,{once:true});try{socket.close(1000,'task switch')}catch{}setTimeout(finish,650)})}
async function stopTaskSession(){stopTimers();clearWSReconnect();await closeWS();submitting=false}
function taskCardName(t){return String(t.display_name||'').trim()||`Task ${String(t.id||'').slice(-8)}`}
function renderTaskCards(items){const host=$('taskCards');items=Array.isArray(items)?items:[];host.innerHTML=items.length?items.map((t,i)=>`<article class="task-card glass ${t.selected?'selected':''} ${t.paused?'paused':''}">
<div class="task-card-top"><span class="task-orbit">${String(i+1).padStart(2,'0')}</span><div><span class="eyebrow">${t.selected?'DEIN AKTUELLER TASK':'ACTIVE FIELD'}</span><h2>${esc(taskCardName(t))}</h2><code>${esc(shortID(t.id.slice(-16),16))}</code></div><span class="task-bit">${t.range_bits}<small>BIT</small></span></div>
<div class="task-style-preview"><img src="/api/public/tasks/${encodeURIComponent(t.id)}/style-reference?v=${encodeURIComponent(t.revision||0)}" alt="Style-Referenz für ${esc(taskCardName(t))}"><span>${t.has_custom_style_reference?'TASK STYLE':'DEFAULT STYLE'}</span></div>
<p>${esc(t.description||'Ein aktiver Neural-Hunt-Zahlenraum. Bewege dein Signal mit jedem besseren Tipp näher an den Task-Kern.')}</p>
<div class="task-card-stats"><span><small>CLIENTS</small><b>${Number(t.point_count||0).toLocaleString('de-DE')}</b></span><span><small>DEIN SCORE</small><b>${fmtScore(t.own_score)}</b></span><span><small>DEIN RANK</small><b>${t.own_rank?`#${t.own_rank}`:'—'}</b></span><span><small>STATUS</small><b>${t.paused?'PAUSE':'LIVE'}</b></span></div>
<button data-task-choice="${esc(t.id)}">${t.selected?'FORTSETZEN':'TASK WÄHLEN'} <span>→</span></button>
</article>`).join(''):'<div class="task-card-empty glass"><b>Keine aktiven Tasks</b><span>Der Server erzeugt gerade einen neuen Wahrscheinlichkeitsraum.</span></div>';
document.querySelectorAll('[data-task-choice]').forEach(b=>b.onclick=()=>enterTask(b.dataset.taskChoice));
}
async function showLanding(){if(landingBusy)return;landingBusy=true;try{await stopTaskSession();task=null;points=[];render();$('taskid').textContent='—';$('taskLanding').classList.add('visible');status('Task auswählen');const items=await api('/api/tasks');renderTaskCards(items)}catch(e){status(e.message||'Tasks konnten nicht geladen werden');$('taskCards').innerHTML=`<div class="task-card-empty glass"><b>Fehler beim Laden</b><span>${esc(e.message||'Unbekannter Fehler')}</span></div>`}finally{landingBusy=false}}
async function refreshTaskConfig(forcePoints=false){if(refreshing||!task)return;refreshing=true;try{const current=await api('/api/tasks/current');if(task&&current.id!==task.id){setTimeout(()=>showLanding(),0);return}const changed=current.revision!==task.revision||current.public_seed!==task.public_seed||current.range_bits!==task.range_bits;task=current;$('taskid').textContent=task.display_name?shortID(task.display_name,16):shortID(task.id.slice(-12),12);if(changed||forcePoints){points=await api(`/api/tasks/${task.id}/points?limit=${Math.min(10000,Math.max(300,(+$('maxnodes').value||task.default_max_nodes||2000)*3))}`);points=Array.isArray(points)?points:[];render()}if(task.paused){status(`Task pausiert · ${task.range_bits} Bit`);nextGuessAt=0}else if(changed){status(`Task aktualisiert · ${task.range_bits} Bit`);nextGuessAt=Date.now()+Math.max(1,task.client_submit_interval_sec)*1000}}finally{refreshing=false}}
function scheduleWSReconnect(){if(wsReconnectTimer||!task||$('taskLanding').classList.contains('visible'))return;const delay=Math.min(8000,wsBackoff)+Math.floor(Math.random()*250);wsReconnectTimer=setTimeout(()=>{wsReconnectTimer=null;openWS()},delay);wsBackoff=Math.min(8000,Math.max(750,wsBackoff*1.7))}
async function recover409(e){
if(e.code==='task_inactive'||e.code==='selection_conflict'){await showLanding();return}
try{const current=await api('/api/tasks/current');if(!task||current.id!==task.id){await showLanding();return}task=current;$('taskid').textContent=task.display_name?shortID(task.display_name,16):shortID(task.id.slice(-12),12)}catch{}
if(e.code==='presence_required')openWS(true);else if(!wsReady())scheduleWSReconnect();
if(e.code==='task_config_changed')await refreshTaskConfig(true);
nextGuessAt=Date.now()+1500;status(e.code==='presence_required'?'Live-Verbindung wird automatisch wiederhergestellt …':'Client wird automatisch synchronisiert …')
}
async function submit(){if(!task||submitting)return;if(!wsReady()){scheduleWSReconnect();nextGuessAt=Date.now()+1000;status('Live-Verbindung wird wiederhergestellt …');return}submitting=true;status('signiert Tipp …','thinking');try{const current=await api('/api/tasks/current');if(current.id!==task.id){await showLanding();return}task=current;if(task.paused){status('Task pausiert');nextGuessAt=0;return}const seq=task.next_seq,guess=await deterministicGuess(task.id,task.public_seed,cid,seq,task.range_bits),signature=await sign(`guess|${task.id}|${seq}|${guess}`),correct=await api(`/api/tasks/${task.id}/guess`,{method:'POST',body:JSON.stringify({seq,guess,signature})});nextGuessAt=Date.now()+Math.max(1,task.client_submit_interval_sec)*1000;status(correct?'Treffer — Task gelöst!':'Tipp akzeptiert',correct?'researching':'living');await Promise.all([refreshMe(),refreshLeaders()]);if(correct)setTimeout(()=>showLanding(),1300)}catch(e){if(e.status===409){await recover409(e);return}nextGuessAt=Date.now()+Math.max(2,task?.client_submit_interval_sec||11)*1000;status(e.message||'Tipp fehlgeschlagen')}finally{submitting=false}}
function openWS(force=false){if(!task||$('taskLanding').classList.contains('visible'))return;clearWSReconnect();if(ws&&(ws.readyState===WebSocket.OPEN||ws.readyState===WebSocket.CONNECTING)){if(!force)return;const old=ws;old._plannedClose=true;try{old.close(1000,'reconnect')}catch{}}const proto=location.protocol==='https:'?'wss':'ws',mx=+$('maxnodes').value||task.default_max_nodes||2000,socket=new WebSocket(`${proto}://${location.host}/api/ws?token=${encodeURIComponent(getToken())}&max_nodes=${encodeURIComponent(mx)}`);ws=socket;socket.onopen=()=>{if(ws!==socket)return;wsBackoff=500;status(task?.paused?'Task pausiert':'verbunden')};socket.onmessage=async ev=>{if(ws!==socket)return;const e=JSON.parse(ev.data);if(e.type==='snapshot'){points=Array.isArray(e.data)?e.data:[];render()}else if(e.type==='point'){const p=e.data,i=points.findIndex(x=>x.client_id===p.client_id);if(i<0)points.push(p);else points[i]=p;render()}else if(e.type==='points'){for(const p of (Array.isArray(e.data)?e.data:[])){const i=points.findIndex(x=>x.client_id===p.client_id);if(i<0)points.push(p);else points[i]=p}render()}else if(e.type==='task_changed'){await refreshTaskConfig(true);await Promise.all([refreshMe(),refreshLeaders()])}else if(e.type==='task_completed'){status('Task abgeschlossen — Folge-Task ist bereit','researching');setTimeout(()=>showLanding(),1300)}};socket.onclose=e=>{if(ws===socket)ws=null;if(!socket._plannedClose&&task&&!$('taskLanding').classList.contains('visible')){status('Live-Verbindung unterbrochen · verbinde automatisch neu …');scheduleWSReconnect()}};socket.onerror=()=>{if(!socket._plannedClose&&ws===socket)status('WebSocket-Fehler · Reconnect folgt automatisch')}}
async function enterTask(taskID){if(landingBusy)return;landingBusy=true;try{await stopTaskSession();task=await api('/api/tasks/select',{method:'POST',body:JSON.stringify({task_id:taskID})});$('taskLanding').classList.remove('visible');$('taskid').textContent=task.display_name?shortID(task.display_name,16):shortID(task.id.slice(-12),12);let max=+$('maxnodes').value||clamp(Number(task.default_max_nodes||2000),100,25000);if(document.documentElement.classList.contains('mobile-mode'))max=Math.min(max,1000);$('maxnodes').value=max;$('maxnodesvalue').textContent=max.toLocaleString('de-DE');points=await api(`/api/tasks/${task.id}/points?limit=${Math.min(10000,Math.max(300,max*3))}`);points=Array.isArray(points)?points:[];render();await Promise.all([refreshMe(),refreshLeaders()]);syncMobile();openWS();status(task.paused?'Task pausiert':`${task.range_bits} Bit · verbunden`);nextGuessAt=task.paused?0:Date.now()+Math.max(1,task.client_submit_interval_sec)*1000;scheduler=setInterval(()=>{if(task&&!task.paused&&nextGuessAt&&Date.now()>=nextGuessAt)submit()},300);countdownTimer=setInterval(countdown,250);countdown()}catch(e){status(e.message||'Task konnte nicht gestartet werden');$('taskLanding').classList.add('visible')}finally{landingBusy=false}}
try{const id=await ensureIdentity();cid=await clientId(id.publicJwk);$('cid').textContent=cid;$('landingCid').textContent=cid;await ensureSession();syncMobile();await Promise.all([refreshLeaders()]);await showLanding()}catch(e){status(e.message||'Startfehler');$('taskCards').innerHTML=`<div class="task-card-empty glass"><b>Startfehler</b><span>${esc(e.message||'')}</span></div>`}
$('landingRefresh').onclick=()=>showLanding();$('chooseTask').onclick=()=>showLanding();
$('maxnodes').addEventListener('input',e=>{$('maxnodesvalue').textContent=Number(e.target.value).toLocaleString('de-DE');render()});
$('toggleMobile').onclick=()=>toggleMobileMode();$('toggleDetails').onclick=()=>{detailsOpen=!detailsOpen;$('signalPanel').classList.toggle('expanded',detailsOpen);setActive('toggleDetails',detailsOpen)};window.addEventListener('neuralhunt-mobile-mode',syncMobile);
$('toggleProximity').onclick=()=>{map.proximityFocus=!map.proximityFocus;$('toggleProximity').textContent=map.proximityFocus?'TARGET FIELD':'RAW 3D';setActive('toggleProximity',map.proximityFocus)};$('toggleRotate').onclick=()=>{map.autoRotate=!map.autoRotate;setActive('toggleRotate',map.autoRotate)};$('toggleLabels').onclick=()=>{map.labels=!map.labels;setActive('toggleLabels',map.labels)};$('toggleEdges').onclick=()=>{map.edges=!map.edges;setActive('toggleEdges',map.edges)};$('toggleShells').onclick=()=>{map.shells=!map.shells;setActive('toggleShells',map.shells)};$('toggleLOD').onclick=()=>{map.lodEnabled=!map.lodEnabled;map.rebuild();setActive('toggleLOD',map.lodEnabled)};$('toggleEco').onclick=()=>{map.eco=!map.eco;map.resize();setActive('toggleEco',map.eco)};$('resetView').onclick=()=>map.resetView();
$('exportid').onclick=async()=>{const p=prompt('Passphrase für den verschlüsselten Identitäts-Export');if(!p)return;try{const text=await exportIdentity(p),a=document.createElement('a');a.href=URL.createObjectURL(new Blob([text],{type:'application/json'}));a.download=`neuralhunt-identity-${cid.slice(0,10)}.json`;a.click();setTimeout(()=>URL.revokeObjectURL(a.href),5000)}catch(e){status(e.message||'Export fehlgeschlagen')}};
$('importid').onchange=async e=>{const f=e.target.files?.[0];if(!f)return;const p=prompt('Passphrase für diesen Identitäts-Export');if(!p)return;try{await importIdentity(await f.text(),p);clearToken();location.reload()}catch(err){status(err.message||'Import fehlgeschlagen')}};
addEventListener('beforeunload',()=>{stopTimers();clearWSReconnect();if(landingTimer)clearTimeout(landingTimer);if(ws){ws._plannedClose=true;ws.close()}window.removeEventListener('neuralhunt-mobile-mode',syncMobile);map.destroy()},{once:true});
}
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><a href="/admin">ADMIN</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>
<section class="nft-showcase"><div class="nft-showcase-head"><div><span class="eyebrow">WINNER ARTIFACTS</span><h2>NFT-Galerie</h2></div><span><b id="nftCount">0</b> Artefakte · nur Wasserzeichen-Vorschau</span></div><div id="nftGallery" class="nft-gallery"></div></section>
<section class="glass lb-table-wrap"><div class="lb-table-head"><span id="lbCount">0 Clients</span><span>automatische Aktualisierung über WebSocket</span></div><div class="lb-table" id="lbTable"></div></section>
</main>
<div id="nftLightbox" class="nft-lightbox hidden" role="dialog" aria-modal="true" aria-label="NFT Vorschau"><button id="nftClose" aria-label="Vorschau schließen">×</button><div class="nft-lightbox-card glass"><img id="nftLarge" alt="Wasserzeichen-Vorschau des Gewinner-Artefakts"><div><b id="nftLargeTitle">Winner NFT</b><small id="nftLargeMeta">Watermarked preview</small></div></div></div>`;
}
async function runLeaderboard(){
leaderboardShell();let mode='live',rows=[],artifacts=[],ws=null,refreshTimer=null;
const openNFT=a=>{if(!a?.preview_uri)return;$('nftLarge').src=a.preview_uri;$('nftLargeTitle').textContent=`Task ${shortID(a.task_id?.slice(-14),14)}`;$('nftLargeMeta').textContent=`Winner ${shortID(a.winner_client_id,18)} · ${a.range_bits} Bit · WATERMARKED PREVIEW`;$('nftLightbox').classList.remove('hidden')};
const closeNFT=()=>{$('nftLightbox').classList.add('hidden');$('nftLarge').removeAttribute('src')};
const render=()=>{
const q=$('lbSearch').value.trim().toLowerCase(),filtered=rows.filter(x=>!q||String(x.client_id).toLowerCase().includes(q)||String(x.nft_task_id||'').toLowerCase().includes(q));
$('lbCount').textContent=`${filtered.length.toLocaleString('de-DE')} Clients`;
const top=filtered.slice(0,3);
$('lbPodium').innerHTML=top.map((x,i)=>`<div class="podium rank-${i+1} glass">${x.nft_preview_uri?`<img class="podium-nft" src="${esc(x.nft_preview_uri)}" alt="Wasserzeichen NFT Vorschau" loading="lazy">`:`<span class="podium-rank">#${i+1}</span>`}<div><b>#${i+1} · ${esc(shortID(x.client_id,16))}</b><small>${x.connected?'● online':'○ offline'} · Best ${fmtScore(x.best_score)} · ${x.nft_count||0} NFTs</small></div><strong>${mode==='live'?fmtScore(x.live_score):`${x.wins} Wins`}</strong></div>`).join('');
$('lbTable').innerHTML=filtered.map((x,i)=>`<div class="lb-row"><span class="lb-rank">#${i+1}</span><span class="lb-id"><i class="presence ${x.connected?'on':''}"></i><b>${esc(shortID(x.client_id,22))}</b><small>${(x.unlocks||[]).slice(0,3).map(esc).join(' · ')||'keine Unlocks'}</small></span><div class="lb-stats"><span><small>LIVE</small><strong>${fmtScore(x.live_score)}</strong></span><span><small>BEST</small><strong>${fmtScore(x.best_score)}</strong></span><span><small>WINS</small><strong>${x.wins||0}</strong></span><span><small>TIPPS</small><strong>${Number(x.guess_count||0).toLocaleString('de-DE')}</strong></span></div><span class="lb-nft-cell">${x.nft_preview_uri?`<button class="lb-nft-button" data-nft-task="${esc(x.nft_task_id||'')}"><img src="${esc(x.nft_preview_uri)}" alt="Wasserzeichen NFT Vorschau" loading="lazy"><em>${x.nft_count||1}× NFT</em></button>`:'<small>—</small>'}</span></div>`).join('')||'<div class="empty">Noch keine Teilnehmer</div>';
const shownArtifacts=artifacts.filter(a=>!q||String(a.winner_client_id).toLowerCase().includes(q)||String(a.task_id).toLowerCase().includes(q));
$('nftCount').textContent=shownArtifacts.length.toLocaleString('de-DE');
$('nftGallery').innerHTML=shownArtifacts.length?shownArtifacts.map(a=>`<button class="nft-card" data-gallery-task="${esc(a.task_id)}"><span class="nft-image-wrap"><img src="${esc(a.preview_uri)}" alt="Wasserzeichen-Vorschau für Task ${esc(shortID(a.task_id,12))}" loading="lazy"><i>WATERMARKED</i></span><span><b>${esc(shortID(a.task_id.slice(-16),16))}</b><small>Winner ${esc(shortID(a.winner_client_id,14))} · ${a.range_bits} Bit</small></span></button>`).join(''):'<div class="empty">Noch keine fertigen Gewinner-Artefakte</div>';
document.querySelectorAll('[data-gallery-task]').forEach(b=>b.onclick=()=>openNFT(artifacts.find(a=>a.task_id===b.dataset.galleryTask)));
document.querySelectorAll('[data-nft-task]').forEach(b=>b.onclick=()=>openNFT(artifacts.find(a=>a.task_id===b.dataset.nftTask)||{task_id:b.dataset.nftTask,winner_client_id:filtered.find(x=>x.nft_task_id===b.dataset.nftTask)?.client_id,range_bits:'—',preview_uri:filtered.find(x=>x.nft_task_id===b.dataset.nftTask)?.nft_preview_uri}));
};
async function refresh(){try{const [x,a]=await Promise.all([api(`/api/public/leaderboard?mode=${mode}&limit=500`),api('/api/public/artifacts?limit=96')]);rows=Array.isArray(x)?x:[];artifacts=Array.isArray(a)?a:[];$('lbState').textContent='LIVE · '+new Date().toLocaleTimeString('de-DE');render()}catch(e){$('lbState').textContent=e.message}}
const debounce=()=>{clearTimeout(refreshTimer);refreshTimer=setTimeout(refresh,120)};
$('lbLive').onclick=()=>{mode='live';setActive('lbLive',true);setActive('lbAll',false);refresh()};$('lbAll').onclick=()=>{mode='alltime';setActive('lbLive',false);setActive('lbAll',true);refresh()};$('lbSearch').oninput=render;
$('lbMobile').onclick=()=>{toggleMobileMode();$('lbMobile').textContent=mobileButtonLabel();setActive('lbMobile',document.documentElement.classList.contains('mobile-mode'))};$('lbMobile').textContent=mobileButtonLabel();setActive('lbMobile',document.documentElement.classList.contains('mobile-mode'));
$('nftClose').onclick=closeNFT;$('nftLightbox').onclick=e=>{if(e.target===$('nftLightbox'))closeNFT()};addEventListener('keydown',e=>{if(e.key==='Escape')closeNFT()});
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 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><a href="/">← Client-Ansicht</a></div>`}
function adminShell(){
app.className='admin';app.innerHTML=`
<header class="admin-top glass"><div class="brand">${markHTML()}<div><strong>NEURAL HUNT / ADMIN</strong><small>Tasks · Clients · Runtime · Scheduler</small></div></div><div class="admin-actions"><button id="adminMobileToggle">MOBILE</button><a href="/">CLIENT</a><a href="/leaderboard">LEADERBOARD</a><button id="adminlogout">LOGOUT</button></div></header>
<div class="overviewStrip glass" id="overview"></div><div class="overviewStrip glass perf-strip" id="performance"></div>
<nav class="admin-mobile-tabs glass" id="adminMobileTabs"><button data-admin-panel="map" class="active">MAP</button><button data-admin-panel="tasks">TASKS</button><button data-admin-panel="control">CONTROL</button></nav>
<main class="adminGrid mobile-show-map" id="adminGrid">
<section class="glass tasklist"><div class="panel-title"><span>TASKS</span><span class="chip" id="taskCount">0</span></div><div class="toolbar"><select id="statusfilter"><option value="">alle Status</option><option>active</option><option>completed</option><option>closed</option></select><input id="taskquery" placeholder="Task / Winner"><button id="filter">FILTER</button></div><div class="tasks" id="tasks"></div></section>
<section class="adminMap glass"><div id="adminmap" class="neural-map"></div><div class="map-overlay top"><span><b id="selectedtask">Task wählen</b></span><span><b id="adminbits">—</b> Bit</span><span><b id="adminpoints">0</b> Clients</span><span><b id="adminrender">0</b> Render</span><span><b id="adminfps">0</b> FPS</span><span id="winner"></span><label class="inline-filter">Score ≥ <input id="adminMinScore" type="number" min="0" max="100" step="1" value="0"></label><input id="adminClientFilter" class="client-filter" placeholder="Client-ID filtern"></div><div class="map-overlay bottom"><label>MAX NODES <input id="adminmaxnodes" type="range" min="100" max="50000" step="100" value="5000"><b id="adminmaxvalue">5.000</b></label><button id="adminProximity" class="active">TARGET FIELD</button><button id="adminRotate" class="active">ORBIT</button><button id="adminEdges" class="active">SIGNALWEGE</button><button id="adminShells" class="active">SCORE-RINGE</button><button id="adminLOD" class="active">LOD</button><button id="adminEco">ECO</button><button id="adminReset">RESET</button></div></section>
<section class="glass settings"><div class="panel-title"><span>CONTROL PLANE</span><span class="chip">SQLite</span></div><div class="settings-tabs"><button id="tabRuntime" class="active">RUNTIME</button><button id="tabTask">TASK ACTIONS</button><button id="tabArtifact">ARTIFACT</button></div><div id="settingfields"></div><div class="actions"><button id="savesettings">SPEICHERN</button><button id="ensuretasks">ACTIVE TASKS SICHERN</button></div><p class="small statusline" id="adminstatus"></p></section>
</main>`;
}
async function runAdmin(){
if(!localStorage.getItem(adminTokenKey)){adminLoginShell();$('adminlogin').onclick=async()=>{try{const r=await fetch('/api/admin/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({user:$('adminuser').value,password:$('adminpass').value})});if(!r.ok)throw new Error('Login fehlgeschlagen');const x=await r.json();localStorage.setItem(adminTokenKey,x.token);location.reload()}catch(e){$('adminerr').textContent=e.message}};return}
adminShell();const map=new NeuralMap($('adminmap'),{admin:true,onStats:s=>{if($('adminrender'))$('adminrender').textContent=s.render.toLocaleString('de-DE');if($('adminfps'))$('adminfps').textContent=s.fps}});const adminDraftKey='neuralhunt.adminDraft.v2';let draft={};try{draft=JSON.parse(localStorage.getItem(adminDraftKey)||'{}')||{}}catch{draft={}};let tasks=[],selected=null,points=[],settings=null,actions=[],providers=null,artifactUsage=null,poll=null,tab=['runtime','task','artifact'].includes(draft.tab)?draft.tab:'runtime',loading=false;
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','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)',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 msg=s=>$('adminstatus').textContent=s;
const saveDraft=()=>{try{draft.tab=tab;draft.selectedTaskId=selected?.id||draft.selectedTaskId||'';draft.filters={status:$('statusfilter')?.value||'',q:$('taskquery')?.value||''};localStorage.setItem(adminDraftKey,JSON.stringify(draft))}catch{}};
const draftScope=()=>tab==='task'&&selected?`task:${selected.id}`:`global:${tab}`;
const draftFieldKey=el=>el.dataset.setting?`setting:${el.dataset.setting}`:el.dataset.settingString?`string:${el.dataset.settingString}`:el.dataset.draft||el.id||'';
const captureDraft=()=>{const scope=draftScope();draft.fields=draft.fields||{};draft.fields[scope]=draft.fields[scope]||{};document.querySelectorAll('#settingfields input,#settingfields textarea,#settingfields select').forEach(el=>{if(el.type==='file')return;const k=draftFieldKey(el);if(k)draft.fields[scope][k]=el.value});saveDraft()};
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.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>`}
async function openAdminFile(taskID,kind){const popup=window.open('','_blank');try{const token=localStorage.getItem(adminTokenKey),r=await fetch(`/api/admin/tasks/${encodeURIComponent(taskID)}/${kind}`,{headers:{Authorization:'Bearer '+token}});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=>`<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>${esc(t.artifact_status||'—')}<small>${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" data-setting="${k}" value="${settings?.[k]??''}"></label>`).join('')}<p class="small">Diese Defaults gelten für neue Tasks. Task-spezifische Intervalle und Zahlenräume steuerst du im Tab „Task Actions“.</p></div>`;$('savesettings').style.display='inline-block';$('ensuretasks').style.display='inline-block';
}
function renderArtifact(){
const ps=providers?.providers||{},preset=settings?.artifact_preset||'legacy',u=artifactUsage||{},recent=Array.isArray(u.recent)?u.recent:[];
const usd=(n,d=4)=>Number(n||0).toLocaleString('de-DE',{style:'currency',currency:'USD',minimumFractionDigits:d,maximumFractionDigits:d});
const usageRows=recent.length?recent.map(r=>`<tr><td>${fmtDate(r.created_at)}</td><td>${r.kind==='character_anchor'?'ANCHOR':'KARTE'}</td><td>${esc(r.model||'—')}<small>${esc(r.quality||'—')} · ${esc(r.size||'—')}</small></td><td>${Number(r.input_text_tokens||0).toLocaleString('de-DE')} T + ${Number(r.input_image_tokens||0).toLocaleString('de-DE')} I → ${Number(r.output_tokens||0).toLocaleString('de-DE')}</td><td>${r.estimated_cost_usd==null?'—':usd(r.estimated_cost_usd,5)}</td><td><small>${esc(r.request_id?String(r.request_id).slice(-16):'—')}</small></td></tr>`).join(''):'<tr><td colspan="6" class="empty small">Noch keine OpenAI-Bildgenerierung protokolliert.</td></tr>';
if(preset==='raccoon_full_art_v1'){
const anchorReady=!!providers?.character_anchor;
$('settingfields').innerHTML=`<div class="control-section"><div class="section-title">RIFT FULL-ART COLLECTION</div><div class="provider-status"><span class="${ps.openai?'ready':'off'}">OPENAI · ${ps.openai?'API KEY READY':'API KEY FEHLT'}</span><span class="${anchorReady?'ready':'off'}">CHARACTER ANCHOR · ${anchorReady?'LOCKED':'NOCH NICHT ERZEUGT'}</span></div>
<label class="wide"><span>OpenAI Bildmodell</span><input data-setting-string="artifact_model" value="${esc(settings?.artifact_model||'gpt-image-2')}" placeholder="gpt-image-2"></label>
<div class="reference-admin-box"><div><div class="section-title">RIFT CHARACTER ANCHOR</div><p class="small">Der Anchor definiert ausschließlich, <b>wer RIFT ist</b>. Er wird einmalig als neutrales Character-Referenzbild erzeugt und anschließend für alle Tasks verwendet. Die visuelle Stilrichtung kommt getrennt aus der Style-Referenz des jeweiligen Tasks.</p>${anchorReady?'<p class="small ready-copy">Anchor vorhanden · Identität ist gesperrt. Es gibt absichtlich keinen Überschreiben-Button.</p>':'<p class="small">Du kannst den Anchor jetzt kontrolliert erzeugen. Falls du das nicht tust, erzeugt der Worker ihn weiterhin automatisch beim ersten Gewinnerbild als Sicherheits-Fallback.</p>'}</div><div class="reference-preview ${anchorReady?'has-image':''}">${anchorReady?'<img id="anchorPreview" alt="RIFT Character Anchor">':'<span>NO ANCHOR</span>'}</div></div>
${anchorReady?'':`<div class="task-config-actions anchor-actions"><button id="createCharacterAnchor" ${ps.openai?'':'disabled'}>RIFT-ANCHOR JETZT ERZEUGEN</button></div>`}
<div class="task-config-box"><div class="section-title">PIPELINE</div><p class="small">Provider <b>OpenAI</b> · Ausgabe <b>1024 × 1536</b> · Quality <b>${esc(settings?.artifact_quality||'medium')}</b> · Preset <b>raccoon_full_art_v1</b>.</p><p class="small">Jede Karten-Generierung sendet zwei getrennte Referenzen: <b>Image 1 = globaler RIFT-Character-Anchor</b>, <b>Image 2 = Style-Referenz des gewählten Tasks</b>. Ohne eigenen Task-Style wird das eingebettete <code>internal/artifact/assets/style_reference.jpg</code> nur als Default-Style verwendet.</p><p class="small">Theme, Kleidung, Accessoires, Szene, Pose, Stimmung, Farb-Akzente und Rarity werden deterministisch aus Task/Winner/Seed gewählt. Das Modell erzeugt nur die Full-Art-Illustration; das finale Kartenlayout wird anschließend programmgesteuert aufgebaut.</p></div>
<div class="section-title">OPENAI NUTZUNG & KOSTEN</div><div class="cost-grid"><div class="cost-card"><small>KOSTEN HEUTE</small><b id="artifactCostToday">${usd(u.today_cost_usd,4)}</b><span id="artifactCostTodayMeta">${Number(u.today_calls||0).toLocaleString('de-DE')} API-Calls · ${Number(u.today_cards||0).toLocaleString('de-DE')} Karten${Number(u.today_calls||0)>Number(u.today_priced_calls||0)?` · ${(Number(u.today_calls||0)-Number(u.today_priced_calls||0)).toLocaleString('de-DE')} ohne Kostendaten`:''}</span></div><div class="cost-card"><small>Ø KOSTEN PRO KARTE</small><b id="artifactAvgCardCost">${usd(u.avg_card_cost_usd,5)}</b><span id="artifactAvgCardCostMeta">${Number(u.priced_card_generations||0).toLocaleString('de-DE')} bepreiste Generierungen</span></div><div class="cost-card"><small>KOSTEN PRO 1.000 KARTEN</small><b id="artifactCostPer1000">${usd(u.cost_per_1000_usd,2)}</b><span>hochgerechnet aus dem bisherigen Kartenmittel</span></div></div>
<div class="usage-note">Die Token-Nutzung stammt direkt aus der OpenAI-Antwort. Die USD-Werte werden lokal daraus mit fest hinterlegten öffentlichen Standardpreisen berechnet; sie sind kein Rechnungsabgleich. Anchor-Kosten zählen in „heute“, aber nicht in den Karten-Durchschnitt.</div>
<div class="usage-table-wrap"><table class="usage-table"><thead><tr><th>Zeit</th><th>Typ</th><th>Modell</th><th>Tokens (Text + Bild → Output)</th><th>Kosten</th><th>Request</th></tr></thead><tbody id="artifactUsageRows">${usageRows}</tbody></table></div>
<p class="small">Task-spezifische Style-Bilder und optionale kreative Vorgaben pflegst du im Tab <b>TASK ACTIONS</b>.</p></div>`;
if(anchorReady){const img=$('anchorPreview');loadProtectedImage('/api/admin/artifact/character-anchor?ts='+Date.now(),img,true).catch(()=>{if(img)img.alt='Anchor konnte nicht geladen werden'})}
const create=$('createCharacterAnchor');if(create)create.onclick=async()=>{if(!confirm('RIFT Character Anchor jetzt einmalig erzeugen? Danach wird er absichtlich nicht automatisch überschrieben.'))return;create.disabled=true;create.textContent='ANCHOR WIRD ERZEUGT …';try{await api('/api/admin/artifact/character-anchor',{method:'POST'},true);msg('RIFT Character Anchor erzeugt und gesperrt');await load(true,true)}catch(e){msg(e.message);create.disabled=false;create.textContent='RIFT-ANCHOR JETZT ERZEUGEN'}};
}else{
$('settingfields').innerHTML=`<div class="control-section"><div class="section-title">LEGACY ARTIFACT GENERATION</div><div class="provider-status">${['local','openai','comfyui','a1111'].map(k=>`<span class="${ps[k]?'ready':'off'}">${k.toUpperCase()} · ${ps[k]?'READY':'ENV FEHLT'}</span>`).join('')}</div>
<label><span>Provider</span><select data-setting-string="artifact_provider"><option value="local">local</option><option value="openai">openai</option><option value="comfyui">comfyui</option><option value="a1111">a1111</option><option value="auto">auto fallback</option></select></label>
<label><span>Modell / Checkpoint</span><input data-setting-string="artifact_model" value="${esc(settings?.artifact_model||'')}"></label>
<label><span>Quality (OpenAI)</span><select data-setting-string="artifact_quality"><option>auto</option><option>low</option><option>medium</option><option>high</option></select></label>
<label><span>Breite</span><input type="number" data-setting="artifact_width" value="${settings?.artifact_width||1024}"></label><label><span>Höhe</span><input type="number" data-setting="artifact_height" value="${settings?.artifact_height||1024}"></label><label><span>Steps (lokale UIs)</span><input type="number" data-setting="artifact_steps" value="${settings?.artifact_steps||28}"></label>
<label class="wide"><span>Prompt-Zusatz</span><textarea data-setting-string="artifact_prompt" rows="4">${esc(settings?.artifact_prompt||'')}</textarea></label><label class="wide"><span>Negative Prompt</span><textarea data-setting-string="artifact_negative_prompt" rows="3">${esc(settings?.artifact_negative_prompt||'')}</textarea></label></div>`;
const pr=document.querySelector('[data-setting-string="artifact_provider"]'),qu=document.querySelector('[data-setting-string="artifact_quality"]');if(pr)pr.value=settings?.artifact_provider||'local';if(qu)qu.value=settings?.artifact_quality||'medium';
}
$('savesettings').style.display='inline-block';$('ensuretasks').style.display='none';
}
function payloadText(a){try{const p=typeof a.payload==='string'?JSON.parse(a.payload):a.payload;return Object.keys(p||{}).length?JSON.stringify(p):'—'}catch{return '—'}}
function renderTaskControl(){
$('savesettings').style.display='none';$('ensuretasks').style.display='none';if(!selected){$('settingfields').innerHTML='<div class="empty">Links einen Task auswählen.</div>';return}
const customStyle=!!String(selected.nft_style_reference||'').trim();
$('settingfields').innerHTML=`<div class="control-section task-control"><div class="section-title">TASK ${esc(selected.display_name||selected.id.slice(-12))}</div><div class="task-facts"><span><b>${selected.range_bits}</b> Bit</span><span><b>${selected.paused?'PAUSED':selected.status}</b> Status</span><span><b>${selected.revision}</b> Revision</span></div>
<div class="task-config-box"><div class="section-title">TASK-DARSTELLUNG & RIFT ART-DIRECTION</div>${selected.parent_task_id?`<span class="inherit-badge">↳ geerbt von ${esc(String(selected.parent_task_id).slice(-12))}</span>`:'<span class="inherit-badge">ROOT TASK</span>'}<p class="draft-note">Der Folge-Task erbt Anzeigename, Beschreibung, kreative Vorgaben, Ausschlüsse und die Style-Referenz. Entwürfe in Textfeldern bleiben bei Auto-Refresh erhalten.</p>
<label class="wide"><span>Anzeigename</span><input id="taskDisplayName" data-draft="taskDisplayName" maxlength="80" value="${esc(selected.display_name||'')}"></label>
<label class="wide"><span>Beschreibung für Landing-Page</span><textarea id="taskDescription" data-draft="taskDescription" rows="3" maxlength="1200">${esc(selected.description||'')}</textarea></label>
<div class="task-style-admin"><div class="reference-preview has-image"><img id="taskStylePreview" alt="Style-Referenz des Tasks"></div><div class="task-style-controls"><div class="section-title">NFT STYLE-REFERENZ · ${customStyle?'CUSTOM':'DEFAULT'}</div><p class="small">Dieses Bild definiert <b>wie</b> RIFT für diese Task-Serie gerendert wird. Der globale Character Anchor definiert separat <b>wer</b> RIFT ist. Nutzer sehen diese Vorschau auf der Task-Auswahl.</p><p class="small">${customStyle?`Aktuell: <code>${esc(String(selected.nft_style_reference).slice(0,18))}…</code>`:'Kein eigener Style hochgeladen · es wird das eingebettete Default-Style-Bild verwendet.'}</p><input id="taskStyleFile" type="file" accept="image/jpeg,image/png"><div class="task-config-actions style-actions"><button id="uploadTaskStyle">STYLE HOCHLADEN / ERSETZEN</button>${customStyle?'<button id="clearTaskStyle" class="danger-button">AUF DEFAULT ZURÜCK</button>':''}</div></div></div>
<label class="wide"><span>Kreative Vorgaben für RIFT-Karten dieses Tasks · optional</span><textarea id="taskNFTPrompt" data-draft="taskNFTPrompt" rows="6" maxlength="8000" placeholder="Leer lassen = automatische Theme-/Outfit-/Szenen-Generierung. Optional z.B. Winter, elegante Streetwear, keine Waffen …">${esc(selected.nft_prompt_instructions||'')}</textarea></label>
<label class="wide"><span>Zusätzliche Ausschlüsse · optional</span><textarea id="taskNFTNegative" data-draft="taskNFTNegative" rows="3" maxlength="4000" placeholder="Leer lassen = nur globale RIFT-Regeln. Optional z.B. kein Helm, keine Waffen, kein Schnee …">${esc(selected.nft_negative_prompt||'')}</textarea></label>
<div class="task-config-actions"><button id="saveTaskConfig">TASK-KONFIG SPEICHERN</button></div><div class="task-config-box pipeline-test-box"><div class="section-title">LOKALER PIPELINE-TEST</div><p class="small">Erzeugt eine komplette Testkarte <b>ohne OpenAI-Aufruf</b>. Der vorhandene <code>character_anchor.png</code> dient als Mock-Artwork, die Style-Referenz dieses Tasks als Hintergrund. Kartenlayout, Traits, Dateischreiben und SVG-Ausgabe werden lokal durchgespielt. Der echte Task-/Artifact-Status bleibt unverändert.</p><div class="task-config-actions"><button id="runPipelineTest">TEST-KARTE ERZEUGEN · 0 API-TOKENS</button></div></div></div>
<div class="section-title">AKTIONEN / SCHEDULER</div><label><span>Aktion</span><select id="actionType" data-draft="actionType"><option value="set_range_bits">Zahlenraum ändern</option><option value="set_intervals">Intervalle ändern</option><option value="clear_intervals">Intervalle auf Defaults</option><option value="pause">Pausieren</option><option value="resume">Fortsetzen</option><option value="reroll">Ziel neu würfeln</option><option value="close">Task beenden</option><option value="regenerate_artifact">NFT-Bild neu erzeugen</option></select></label><div id="actionPayload"></div>
<label class="wide"><span>Ausführen am</span><div class="schedule-row"><input id="actionAt" data-draft="actionAt" type="datetime-local"><button id="runAction">JETZT</button><button id="scheduleAction">PLANEN</button></div></label><div class="action-warning" id="actionWarning"></div>
<div class="section-title action-history-title">AKTIONSPLAN / AUDIT</div><div class="action-list">${actions.length?actions.map(a=>`<div class="action-item ${esc(a.status)}"><span><b>${esc(actionLabel(a.action_type))}</b><small>${fmtDate(a.execute_at)} · ${esc(payloadText(a))}</small>${a.error?`<small class="danger">${esc(a.error)}</small>`:''}</span><em>${esc(a.status)}</em>${a.status==='pending'?`<button data-cancel-action="${esc(a.id)}">×</button>`:''}</div>`).join(''):'<div class="empty small">Noch keine geplanten Aktionen</div>'}</div></div>`;
const styleImg=$('taskStylePreview');loadProtectedImage(`/api/admin/tasks/${selected.id}/style-reference?ts=${Date.now()}`,styleImg,true).catch(()=>{if(styleImg)styleImg.alt='Style-Referenz konnte nicht geladen werden'});
const uploadStyle=$('uploadTaskStyle');if(uploadStyle)uploadStyle.onclick=async()=>{const file=$('taskStyleFile')?.files?.[0];if(!file){msg('Bitte zuerst ein JPEG- oder PNG-Stylebild auswählen');return}const fd=new FormData();fd.append('file',file,file.name);uploadStyle.disabled=true;uploadStyle.textContent='STYLE WIRD HOCHGELADEN …';try{await api(`/api/admin/tasks/${selected.id}/style-reference`,{method:'PUT',body:fd},true);msg('Task-Style gespeichert · Folge-Task übernimmt ihn');await load(true,true)}catch(e){msg(e.message);uploadStyle.disabled=false;uploadStyle.textContent='STYLE HOCHLADEN / ERSETZEN'}};
const clearStyle=$('clearTaskStyle');if(clearStyle)clearStyle.onclick=async()=>{if(!confirm('Eigenen Task-Style entfernen und wieder den eingebetteten Default-Style verwenden?'))return;try{await api(`/api/admin/tasks/${selected.id}/style-reference`,{method:'DELETE'},true);msg('Task-Style auf Default zurückgesetzt');await load(true,true)}catch(e){msg(e.message)}};
const pipelineTest=$('runPipelineTest');if(pipelineTest)pipelineTest.onclick=async()=>{pipelineTest.disabled=true;pipelineTest.textContent='TEST-KARTE WIRD LOKAL ERZEUGT …';try{const r=await api(`/api/admin/tasks/${selected.id}/pipeline-test`,{method:'POST'},true);const popup=window.open('','_blank');const token=localStorage.getItem(adminTokenKey),resp=await fetch(r.url,{headers:{Authorization:'Bearer '+token}});if(!resp.ok)throw new Error(await responseError(resp,'Testkarte konnte nicht geöffnet werden'));const blob=await resp.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);msg('Lokale Testkarte erzeugt · 0 API-Calls · $0.00')}catch(e){msg(e.message)}finally{pipelineTest.disabled=false;pipelineTest.textContent='TEST-KARTE ERZEUGEN · 0 API-TOKENS'}};
const renderPayload=()=>{const type=$('actionType').value,host=$('actionPayload');if(type==='set_range_bits')host.innerHTML=`<label><span>Task-Zahlenraum (Bit)</span><input id="actionBits" data-draft="actionBits" type="number" min="8" max="128" value="${selected.range_bits}"></label><label><span>Änderungsmodus</span><select id="actionMode" data-draft="actionMode"><option value="preserve">preserve · bestehendes Ziel</option><option value="reroll">reroll · neues Ziel</option></select></label><p class="small">Preserve erhält Seed/Sequenzen und re-skaliert Scores mathematisch. Reroll setzt Scores/Sequenzen zurück.</p>`;else if(type==='set_intervals')host.innerHTML=`<label><span>Server Minimum (s)</span><input id="actionServer" data-draft="actionServer" type="number" min="1" max="3600" value="${selected.guess_min_interval_sec??settings.guess_min_interval_sec}"></label><label><span>Client Submit (s)</span><input id="actionClient" data-draft="actionClient" type="number" min="2" max="7200" value="${selected.client_submit_interval_sec??settings.client_submit_interval_sec}"></label>`;else host.innerHTML=`<p class="small">${type==='reroll'?'Achtung: neues Ziel und neuer öffentlicher Seed; aktuelle Scores werden auf 0 gesetzt.':type==='close'?'Beendet den Task. Der automatisch erzeugte Folge-Task erbt Zahlenraum, Intervalle, Darstellung, RIFT-Prompt und Style-Referenz.':type==='regenerate_artifact'?'Nur für abgeschlossene Tasks: setzt das Artifact wieder auf queued.':'Keine weiteren Parameter.'}</p>`};
const defaultAt=new Date(Date.now()+5*60*1000);defaultAt.setMinutes(defaultAt.getMinutes()-defaultAt.getTimezoneOffset());$('actionAt').value=defaultAt.toISOString().slice(0,16);restoreDraft();renderPayload();restoreDraft();$('actionType').onchange=()=>{renderPayload();restoreDraft();captureDraft()};
$('saveTaskConfig').onclick=async()=>{try{captureDraft();await api(`/api/admin/tasks/${selected.id}/config`,{method:'PUT',body:JSON.stringify({display_name:$('taskDisplayName').value,description:$('taskDescription').value,nft_prompt_instructions:$('taskNFTPrompt').value,nft_negative_prompt:$('taskNFTNegative').value})},true);clearDraftKeys(['taskDisplayName','taskDescription','taskNFTPrompt','taskNFTNegative']);msg('Task-Konfiguration gespeichert · Folge-Task übernimmt sie');await load(true,true)}catch(e){msg(e.message)}};
const submitAction=async runNow=>{try{captureDraft();const type=$('actionType').value,payload={};if(type==='set_range_bits'){payload.bits=Number($('actionBits').value);payload.mode=$('actionMode').value}else if(type==='set_intervals'){payload.server_min_interval_sec=Number($('actionServer').value);payload.client_submit_interval_sec=Number($('actionClient').value)}const execute_at=runNow?null:new Date($('actionAt').value).toISOString();await api(`/api/admin/tasks/${selected.id}/actions`,{method:'POST',body:JSON.stringify({action_type:type,payload,execute_at})},true);msg(runNow?'Aktion ausgeführt':'Aktion geplant');await load(true,true)}catch(e){msg(e.message)}};
$('runAction').onclick=()=>submitAction(true);$('scheduleAction').onclick=()=>submitAction(false);document.querySelectorAll('[data-cancel-action]').forEach(b=>b.onclick=async()=>{try{await api(`/api/admin/actions/${b.dataset.cancelAction}/cancel`,{method:'POST'},true);await load(true,true)}catch(e){msg(e.message)}});
}
// The control plane is intentionally NOT rebuilt by the 3-second telemetry poll.
// Replacing <select> nodes while a native dropdown is open makes the browser close
// the popup and also destroys caret/selection/scroll state in text fields. Drafts
// protect values, but they cannot protect native UI state. Only explicit user
// navigation/actions are allowed to rebuild this subtree.
function refreshArtifactUsageTelemetry(){
if(!$('artifactCostToday'))return;const u=artifactUsage||{},recent=Array.isArray(u.recent)?u.recent:[],usd=(n,d=4)=>Number(n||0).toLocaleString('de-DE',{style:'currency',currency:'USD',minimumFractionDigits:d,maximumFractionDigits:d});
$('artifactCostToday').textContent=usd(u.today_cost_usd,4);$('artifactCostTodayMeta').textContent=`${Number(u.today_calls||0).toLocaleString('de-DE')} API-Calls · ${Number(u.today_cards||0).toLocaleString('de-DE')} Karten${Number(u.today_calls||0)>Number(u.today_priced_calls||0)?` · ${(Number(u.today_calls||0)-Number(u.today_priced_calls||0)).toLocaleString('de-DE')} ohne Kostendaten`:''}`;$('artifactAvgCardCost').textContent=usd(u.avg_card_cost_usd,5);$('artifactAvgCardCostMeta').textContent=`${Number(u.priced_card_generations||0).toLocaleString('de-DE')} bepreiste Generierungen`;$('artifactCostPer1000').textContent=usd(u.cost_per_1000_usd,2);
$('artifactUsageRows').innerHTML=recent.length?recent.map(r=>`<tr><td>${fmtDate(r.created_at)}</td><td>${r.kind==='character_anchor'?'ANCHOR':'KARTE'}</td><td>${esc(r.model||'—')}<small>${esc(r.quality||'—')} · ${esc(r.size||'—')}</small></td><td>${Number(r.input_text_tokens||0).toLocaleString('de-DE')} T + ${Number(r.input_image_tokens||0).toLocaleString('de-DE')} I → ${Number(r.output_tokens||0).toLocaleString('de-DE')}</td><td>${r.estimated_cost_usd==null?'—':usd(r.estimated_cost_usd,5)}</td><td><small>${esc(r.request_id?String(r.request_id).slice(-16):'—')}</small></td></tr>`).join(''):'<tr><td colspan="6" class="empty small">Noch keine OpenAI-Bildgenerierung protokolliert.</td></tr>';
}
function renderSettings(){setActive('tabRuntime',tab==='runtime');setActive('tabTask',tab==='task');setActive('tabArtifact',tab==='artifact');if(tab==='runtime')renderRuntime();else if(tab==='artifact')renderArtifact();else renderTaskControl();restoreDraft()}
function renderMap(){points=Array.isArray(points)?points:[];const min=Number($('adminMinScore')?.value||0),needle=String($('adminClientFilter')?.value||'').trim().toLowerCase(),filtered=points.filter(p=>Number(p.score||0)>=min&&(!needle||String(p.client_id||'').toLowerCase().includes(needle)));$('adminpoints').textContent=`${filtered.length.toLocaleString('de-DE')} / ${points.length.toLocaleString('de-DE')}`;$('selectedtask').textContent=selected?.id||'Task wählen';$('adminbits').textContent=selected?.range_bits??'—';$('winner').textContent=selected?.winner_client_id?`Winner ${shortID(selected.winner_client_id,12)}`:selected?.paused?'PAUSED':'';map.update(filtered,'',+$('adminmaxnodes').value)}
async function refreshSelected(refreshControls=false){if(!selected){points=[];actions=[];renderMap();if(refreshControls)renderSettings();return}try{const [ps,as]=await Promise.all([api(`/api/admin/tasks/${selected.id}/points?limit=100000`,{},true),api(`/api/admin/tasks/${selected.id}/actions?limit=100`,{},true)]);points=Array.isArray(ps)?ps:[];actions=Array.isArray(as)?as:[];renderMap();if(refreshControls)renderSettings()}catch(e){msg(e.message)}}
async function load(keepMessage=false,refreshControls=false){if(loading)return;loading=true;try{const status=$('statusfilter').value,q=$('taskquery').value,dayStart=new Date();dayStart.setHours(0,0,0,0);const [ts,st,ov,pv,pf,au]=await Promise.all([api(`/api/admin/tasks?status=${encodeURIComponent(status)}&q=${encodeURIComponent(q)}&limit=300`,{},true),api('/api/admin/settings',{},true),api('/api/admin/overview',{},true),api('/api/admin/artifact/providers',{},true),api('/api/admin/performance',{},true),api(`/api/admin/artifact/usage?day_start_ms=${dayStart.getTime()}`,{},true)]);tasks=Array.isArray(ts)?ts:[];settings=st||{};providers=pv||{};artifactUsage=au||{};refreshArtifactUsageTelemetry();renderOverview(ov||{});renderPerformance(pf||{});if(selected){selected=tasks.find(t=>t.id===selected.id)||selected}renderTasks();if(selected)await refreshSelected(refreshControls);else if(refreshControls)renderSettings()}catch(e){if(e.status===401){localStorage.removeItem(adminTokenKey);location.reload();return}msg(e.message||'Laden fehlgeschlagen')}finally{loading=false}}
async function openTask(t){captureDraft();selected=t;draft.selectedTaskId=t?.id||'';saveDraft();await refreshSelected(true);renderTasks();if(document.documentElement.classList.contains('mobile-mode'))setAdminPanel('map')}
$('filter').onclick=()=>{saveDraft();load()};$('statusfilter').onchange=()=>{saveDraft();load()};$('taskquery').addEventListener('input',saveDraft);$('taskquery').addEventListener('keydown',e=>{if(e.key==='Enter'){saveDraft();load()}});$('adminmaxnodes').oninput=e=>{$('adminmaxvalue').textContent=Number(e.target.value).toLocaleString('de-DE');renderMap()};$('adminMinScore').oninput=renderMap;$('adminClientFilter').oninput=renderMap;
$('adminProximity').onclick=()=>{map.proximityFocus=!map.proximityFocus;$('adminProximity').textContent=map.proximityFocus?'TARGET FIELD':'RAW 3D';setActive('adminProximity',map.proximityFocus)};$('adminRotate').onclick=()=>{map.autoRotate=!map.autoRotate;setActive('adminRotate',map.autoRotate)};$('adminEdges').onclick=()=>{map.edges=!map.edges;setActive('adminEdges',map.edges)};$('adminShells').onclick=()=>{map.shells=!map.shells;setActive('adminShells',map.shells)};$('adminLOD').onclick=()=>{map.lodEnabled=!map.lodEnabled;map.rebuild();setActive('adminLOD',map.lodEnabled)};$('adminEco').onclick=()=>{map.eco=!map.eco;map.resize();setActive('adminEco',map.eco)};$('adminReset').onclick=()=>map.resetView();
$('tabRuntime').onclick=()=>{captureDraft();tab='runtime';saveDraft();renderSettings()};$('tabTask').onclick=()=>{captureDraft();tab='task';saveDraft();renderSettings()};$('tabArtifact').onclick=()=>{captureDraft();tab='artifact';saveDraft();renderSettings()};
$('savesettings').onclick=async()=>{try{captureDraft();const out={...settings};document.querySelectorAll('[data-setting]').forEach(i=>out[i.dataset.setting]=Number(i.value));document.querySelectorAll('[data-setting-string]').forEach(i=>out[i.dataset.settingString]=i.value);settings=await api('/api/admin/settings',{method:'PUT',body:JSON.stringify(out)},true);if(draft.fields)delete draft.fields[draftScope()];saveDraft();msg('gespeichert');renderSettings()}catch(e){msg(e.message)}};
$('ensuretasks').onclick=async()=>{try{await api('/api/admin/tasks/ensure',{method:'POST'},true);msg('aktive Tasks sichergestellt');await load(true,true)}catch(e){msg(e.message)}};
$('adminlogout').onclick=()=>{localStorage.removeItem(adminTokenKey);location.reload()};
$('settingfields').addEventListener('input',captureDraft);$('settingfields').addEventListener('change',captureDraft);if(draft.filters){$('statusfilter').value=draft.filters.status||'';$('taskquery').value=draft.filters.q||''}await load();const first=tasks.find(t=>t.id===draft.selectedTaskId)||(tasks.find(t=>t.status==='active')||tasks[0]);if(first)await openTask(first);else renderSettings();poll=setInterval(()=>load(true,false),3000);addEventListener('beforeunload',()=>{captureDraft();clearInterval(poll);window.removeEventListener('neuralhunt-mobile-mode',syncAdminMobile);map.destroy()},{once:true});
}
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>`});
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="color-scheme" content="dark" />
<meta name="theme-color" content="#02050b" />
<title>Neural Hunt</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<div id="app"></div>
<script src="/app.js" defer></script>
</body>
</html>
+100
View File
@@ -0,0 +1,100 @@
:root{color-scheme:dark;--bg:#02050b;--panel:rgba(5,12,24,.68);--line:rgba(133,200,255,.16);--text:#eaf7ff;--muted:#7792a8;--cyan:#52e7ff;--blue:#4b7bff;--violet:#b775ff;--amber:#ffb452;--green:#5dffbd;--red:#ff5f88}
*{box-sizing:border-box}html,body,#app{margin:0;width:100%;height:100%;font-family:Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;color:var(--text)}body{overflow:hidden;background:radial-gradient(circle at 50% 44%,#07192d 0,#02050b 58%,#010207 100%)}button,.button,input,select{font:inherit}button,.button{border:1px solid rgba(82,231,255,.2);background:rgba(82,231,255,.055);color:#a9dbe8;border-radius:10px;padding:8px 10px;font-weight:700;letter-spacing:.06em;font-size:10px;cursor:pointer;text-decoration:none}button:hover,.button:hover{background:rgba(82,231,255,.12);color:#e5fbff}button:disabled{opacity:.42;cursor:not-allowed}input,select{border:1px solid rgba(133,200,255,.15);background:rgba(2,8,17,.78);color:var(--text);border-radius:9px;padding:8px;outline:0}.glass{border:1px solid var(--line);background:linear-gradient(145deg,rgba(9,22,40,.82),rgba(3,8,17,.61));backdrop-filter:blur(18px);box-shadow:inset 0 1px rgba(255,255,255,.035),0 18px 60px rgba(0,0,0,.28)}
.stage,.neural-map{position:fixed;inset:0}.brain-canvas{display:block;width:100%;height:100%;cursor:grab;touch-action:none}.brain-canvas:active{cursor:grabbing}.vignette{pointer-events:none;position:fixed;inset:0;background:radial-gradient(circle at center,transparent 47%,rgba(0,0,0,.72) 100%),linear-gradient(rgba(82,231,255,.015) 1px,transparent 1px);background-size:auto,100% 4px;mix-blend-mode:screen}.tooltip{position:absolute;z-index:20;pointer-events:none;background:rgba(2,8,16,.94);border:1px solid rgba(82,231,255,.25);border-radius:10px;padding:8px 10px;font-size:10px;color:#dcecf5;max-width:250px;box-shadow:0 0 30px rgba(0,0,0,.4)}.tooltip strong{display:block;font-size:11px;color:#eef9ff;margin-bottom:3px}.tooltip small{display:block;color:#84a0b3;line-height:1.4}.hidden{display:none!important}
.topbar{position:fixed;z-index:5;left:18px;right:18px;top:18px;height:64px;border-radius:18px;display:grid;grid-template-columns:minmax(260px,1fr) auto minmax(330px,1fr);gap:18px;align-items:center;padding:0 20px}.brand{display:flex;gap:13px;align-items:center}.brand strong{display:block;letter-spacing:.18em;font-size:13px}.brand small{display:block;color:var(--muted);letter-spacing:.06em;font-size:9px;margin-top:4px}.mark{position:relative;width:34px;height:34px;display:block;flex:0 0 auto}.mark:before,.mark:after,.mark i{content:"";position:absolute;border:1px solid var(--cyan);border-radius:50%;box-shadow:0 0 14px rgba(82,231,255,.7)}.mark:before{width:8px;height:8px;left:13px;top:13px;background:var(--cyan)}.mark:after{width:28px;height:28px;left:2px;top:2px;opacity:.35}.mark i{width:4px;height:4px}.mark i:nth-child(1){left:1px;top:15px}.mark i:nth-child(2){right:1px;top:5px}.mark i:nth-child(3){right:3px;bottom:3px}.metrics{display:flex;gap:17px;align-items:center;justify-self:end;font-size:10px;color:var(--muted);letter-spacing:.05em}.metrics b{font-size:14px;color:var(--text);font-variant-numeric:tabular-nums}.state{border-left:1px solid var(--line);padding-left:16px;color:var(--green);white-space:nowrap}.state i{display:inline-block;width:7px;height:7px;border-radius:50%;background:currentColor;box-shadow:0 0 12px currentColor;margin-right:6px;animation:pulse 1.6s infinite}@keyframes pulse{50%{opacity:.35;transform:scale(.72)}}
.mode-status{justify-self:center;display:flex;align-items:center;gap:9px;min-width:168px;padding:7px 12px;border:1px solid rgba(82,231,255,.16);border-radius:12px;background:rgba(82,231,255,.045);transition:.3s}.mode-status>i{width:9px;height:9px;border-radius:50%;background:var(--cyan);box-shadow:0 0 16px var(--cyan);animation:modeBreath 2.8s ease-in-out infinite}.mode-status b{display:block;font-size:10px;letter-spacing:.16em}.mode-status small{display:block;margin-top:2px;font-size:9px;color:#6f8da2}.mode-status.thinking{border-color:rgba(255,180,82,.34);background:rgba(255,180,82,.08)}.mode-status.thinking>i{background:var(--amber);box-shadow:0 0 18px var(--amber);animation-duration:.72s}.mode-status.researching{border-color:rgba(93,255,189,.34);background:rgba(93,255,189,.08)}.mode-status.researching>i{background:var(--green);box-shadow:0 0 18px var(--green);animation-duration:.82s}@keyframes modeBreath{50%{opacity:.42;transform:scale(.72)}}
.signal-panel{position:fixed;z-index:5;right:18px;top:98px;bottom:18px;width:320px;border-radius:18px;padding:14px;display:flex;flex-direction:column;overflow:hidden}.panel-title{height:34px;display:flex;align-items:center;justify-content:space-between;color:#9fb6c8;font-size:10px;font-weight:800;letter-spacing:.19em;border-bottom:1px solid var(--line);margin-bottom:10px}.chip{padding:4px 7px;border-radius:99px;background:rgba(82,231,255,.08);color:var(--cyan);letter-spacing:.08em}.rank-hero{padding:12px 2px 14px;border-bottom:1px solid var(--line)}.rank-hero small{display:block;color:var(--muted);font-size:9px;letter-spacing:.16em}.rank-hero strong{display:block;font-size:46px;line-height:1;margin-top:5px;letter-spacing:-.04em}.signal-metrics{display:grid;grid-template-columns:repeat(3,1fr);gap:7px;padding:11px 0;border-bottom:1px solid var(--line)}.signal-metrics div{padding:8px;border:1px solid rgba(133,200,255,.1);border-radius:10px;background:rgba(255,255,255,.018)}.signal-metrics span{display:block;color:#668397;font-size:8px;text-transform:uppercase;letter-spacing:.1em}.signal-metrics b{display:block;margin-top:5px;font-size:15px}.identity-block{padding:12px 0;border-bottom:1px solid var(--line)}.eyebrow{display:block;color:#7f9bae;font-size:8px;font-weight:800;letter-spacing:.14em;margin-bottom:6px}.identity-block code{display:block;color:#9dddeb;font-size:9px;word-break:break-all;max-height:42px;overflow:hidden}.identity-actions{display:flex;gap:6px;margin-top:9px}.identity-actions>*{flex:1;text-align:center}.chips{display:flex;gap:5px;flex-wrap:wrap;margin-top:8px}.chips span{font-size:8px;padding:3px 6px;border:1px solid rgba(93,255,189,.18);background:rgba(93,255,189,.06);color:#baffde;border-radius:999px}.leaderboard-head{display:flex;justify-content:space-between;align-items:center;padding:12px 0 7px;font-size:9px;font-weight:800;letter-spacing:.13em}.leaderboard-head small{color:#657f91;font-size:7px;font-weight:500;letter-spacing:.04em}.leaderboard{overflow:auto;min-height:0;flex:1;padding-right:2px}.leader-row{display:grid;grid-template-columns:24px 1fr auto;gap:8px;align-items:center;padding:7px 5px;border-bottom:1px solid rgba(133,200,255,.08);font-size:9px}.leader-row.self{background:rgba(82,231,255,.055);border-radius:8px}.leader-rank{color:#607c8f;text-align:center}.leader-row span:nth-child(2){display:grid}.leader-row b{font-size:9px;color:#d9f5ff}.leader-row small{font-size:7px;color:#667f90;margin-top:2px}.leader-row strong{font-size:11px;color:#7eeaff}
.node-limit{position:fixed;z-index:5;left:18px;bottom:18px;width:278px;border-radius:14px;padding:9px 11px;display:grid;grid-template-columns:auto 1fr auto;gap:9px;align-items:center}.node-limit span{font-size:8px;font-weight:800;letter-spacing:.14em;color:#7c98aa}.node-limit input{padding:0;width:100%}.node-limit b{font-size:10px;font-variant-numeric:tabular-nums}.dock{position:fixed;z-index:6;bottom:18px;left:50%;transform:translateX(-50%);display:flex;gap:5px;border-radius:15px;padding:6px;max-width:calc(100vw - 650px);overflow:auto}.dock button,.dock-link{border:1px solid transparent;background:transparent;color:#6e8799;border-radius:11px;padding:9px 10px;font-weight:700;letter-spacing:.07em;font-size:9px;cursor:pointer;white-space:nowrap;text-decoration:none}.dock button.active{color:var(--cyan);border-color:rgba(82,231,255,.18);background:rgba(82,231,255,.07)}.dock button:hover,.dock-link:hover{background:rgba(82,231,255,.08);color:#c8f7ff}.dock-link{color:#a9dbe8;border-color:rgba(82,231,255,.13)}
.login{display:grid;place-items:center;background:radial-gradient(circle at 50% 40%,#07192d,#010207 70%)}.login-card{width:min(390px,92vw);border-radius:18px;padding:22px;display:grid;gap:12px}.login-card .brand{margin-bottom:8px}.login-card a{color:var(--cyan);font-size:10px;text-decoration:none}.danger{color:#ff9bad}.statusline{min-height:1.2em}
.admin{background:#01040a;overflow:hidden}.admin-top{position:fixed;z-index:8;left:12px;right:12px;top:12px;height:58px;border-radius:16px;padding:0 16px;display:flex;align-items:center;justify-content:space-between}.admin-actions{display:flex;gap:8px;align-items:center}.admin-actions a{color:#8cecff;text-decoration:none;font-size:9px;font-weight:800;letter-spacing:.1em}.overviewStrip{position:fixed;z-index:7;left:12px;right:12px;top:78px;height:44px;border-radius:13px;padding:0 14px;display:flex;gap:20px;align-items:center;overflow:auto}.overviewStrip span{font-size:9px;color:#7892a5;white-space:nowrap}.overviewStrip b{color:#e4f9ff;font-size:14px;margin-right:4px}.adminGrid{position:fixed;left:12px;right:12px;top:130px;bottom:12px;display:grid;grid-template-columns:380px minmax(420px,1fr) 300px;gap:10px}.tasklist,.settings,.adminMap{border-radius:16px;min-height:0}.tasklist,.settings{padding:13px;overflow:auto}.toolbar{display:grid;grid-template-columns:105px 1fr auto;gap:6px}.toolbar input,.toolbar select{width:100%;font-size:9px}.tasks{display:grid;gap:6px;margin-top:10px}.tasks>button{display:grid;grid-template-columns:1.25fr 1fr 76px;gap:8px;text-align:left;align-items:center;padding:9px}.tasks>button span{display:grid;min-width:0}.tasks>button b{overflow:hidden;text-overflow:ellipsis}.tasks small{color:#6f8b9d;font-size:7px;margin-top:4px}.tasks .selected{border-color:rgba(82,231,255,.6);box-shadow:0 0 0 1px rgba(82,231,255,.15) inset,0 0 22px rgba(82,231,255,.05)}.task-state{font-style:normal;font-size:7px;text-transform:uppercase;letter-spacing:.08em;color:#8db0c2}.task-state.active{color:var(--green)}.task-state.completed{color:var(--amber)}.task-state.closed{color:#ff8ba0}.artifactLinks a{color:#6feeff;text-decoration:none}.adminMap{position:relative;overflow:hidden}.adminMap .neural-map{position:absolute;inset:0}.map-overlay{position:absolute;z-index:6;left:12px;right:12px;border:1px solid rgba(133,200,255,.14);background:rgba(3,10,19,.7);backdrop-filter:blur(12px);border-radius:12px;padding:8px 10px;display:flex;gap:14px;align-items:center;font-size:9px;color:#7894a6}.map-overlay.top{top:12px}.map-overlay.bottom{bottom:12px}.map-overlay label{display:flex;gap:8px;align-items:center;min-width:260px}.map-overlay input[type=range]{padding:0;min-width:130px}.map-overlay button{padding:7px 8px}.map-overlay button.active{color:var(--cyan);border-color:rgba(82,231,255,.4);background:rgba(82,231,255,.08)}.map-overlay .dangerBtn{margin-left:auto;border-color:rgba(255,95,136,.36);color:#ff9bad}.settings label{display:grid;grid-template-columns:1fr 82px;gap:8px;align-items:center;margin:8px 0;font-size:9px;color:#8ba4b5}.settings .actions{display:flex;gap:7px;flex-wrap:wrap;margin-top:13px}.small{font-size:8px;color:#7892a4}.empty{color:#6c8798;text-align:center;padding:18px;font-size:9px}
@media(max-width:1280px){.topbar{grid-template-columns:1fr auto}.mode-status{display:none}.metrics{grid-column:2}.metrics span:nth-child(-n+2){display:none}.adminGrid{grid-template-columns:310px 1fr}.settings{display:none}.dock{max-width:calc(100vw - 390px)}}
@media(max-width:850px){.topbar{left:9px;right:9px;top:9px;height:56px;padding:0 12px}.brand small{display:none}.metrics span:not(.state){display:none}.signal-panel{right:9px;left:9px;top:auto;bottom:68px;width:auto;height:37vh}.node-limit{left:9px;bottom:9px;width:235px}.dock{left:auto;right:9px;transform:none;bottom:9px;max-width:calc(100vw - 260px)}.dock button:nth-child(2),.dock button:nth-child(3),.dock button:nth-child(4){display:none}.adminGrid{grid-template-columns:1fr;overflow:auto}.tasklist{display:none}.adminMap{height:100%}.map-overlay.bottom{overflow:auto}.map-overlay label{min-width:210px}.overviewStrip{display:none}.adminGrid{top:78px}.admin-top{top:9px;left:9px;right:9px}}
.map-overlay.top .inline-filter{margin-left:auto;display:flex;align-items:center;gap:5px;white-space:nowrap;min-width:auto}.map-overlay.top .inline-filter input{width:58px;padding:5px}.map-overlay.top .client-filter{width:130px;padding:5px 7px;font-size:8px}
/* Proximity-first visualization */
.distance-legend{position:fixed;z-index:5;left:50%;top:98px;transform:translateX(-50%);border-radius:999px;padding:7px 12px;display:flex;align-items:center;gap:8px;font-size:8px;color:#7291a6;letter-spacing:.07em;pointer-events:none;white-space:nowrap}.distance-legend b{color:#c8f7ff;font-size:8px;letter-spacing:.12em}.distance-legend span{font-variant-numeric:tabular-nums}.distance-legend i{width:86px;height:4px;border-radius:999px;background:linear-gradient(90deg,#4b7bff,#52e7ff,#5dffbd,#ffb452,#ff6fbc);box-shadow:0 0 12px rgba(82,231,255,.2)}
.proximity-mini{padding:9px 0;border-bottom:1px solid var(--line)}.proximity-mini .leaderboard-head{padding:0 0 7px}.proximity-row{display:grid;grid-template-columns:30px 1fr 40px;gap:7px;align-items:center;padding:3px 2px;font-size:8px;color:#6e8a9d}.proximity-row>div{height:5px;background:rgba(132,198,230,.08);border-radius:99px;overflow:hidden}.proximity-row>div i{display:block;height:100%;border-radius:99px;background:linear-gradient(90deg,#4b7bff,#52e7ff,#5dffbd,#ffb452,#ff6fbc);box-shadow:0 0 8px rgba(82,231,255,.22)}.proximity-row b{text-align:right;color:#d9f7ff;font-variant-numeric:tabular-nums}.proximity-row.self{color:#fff}.proximity-row.self b{color:#fff}.leaderboard-head a{color:var(--cyan);font-size:7px;text-decoration:none;letter-spacing:.08em}
/* Realtime public leaderboard */
.leaderboard-page{min-height:100%;height:auto;overflow:auto;background:radial-gradient(circle at 50% 15%,#081d31 0,#02050b 52%,#010207 100%);padding:96px 18px 40px}.leaderboard-top{position:fixed;z-index:20;left:18px;right:18px;top:18px;height:64px;border-radius:18px;padding:0 20px;display:flex;align-items:center;justify-content:space-between}.leaderboard-nav{display:flex;gap:8px}.leaderboard-nav a{color:#9befff;text-decoration:none;font-size:9px;font-weight:800;letter-spacing:.1em;padding:8px 10px;border:1px solid rgba(82,231,255,.16);border-radius:10px}.lb-main{max-width:1400px;margin:0 auto;display:grid;gap:18px}.lb-hero{display:grid;grid-template-columns:1fr minmax(380px,.8fr);gap:18px;align-items:end;padding:18px 4px}.lb-title h1{font-size:34px;margin:5px 0 7px;letter-spacing:-.03em}.lb-title p{max-width:650px;color:#7995a8;font-size:12px;line-height:1.6;margin:0}.lb-controls{border-radius:16px;padding:12px;display:grid;grid-template-columns:auto 1fr auto;gap:10px;align-items:center}.segmented{display:flex;gap:5px}.segmented button.active,.settings-tabs button.active{color:var(--cyan);border-color:rgba(82,231,255,.45);background:rgba(82,231,255,.09)}.lb-controls input{min-width:0;width:100%}.lb-controls>span{font-size:8px;color:var(--green);white-space:nowrap}.lb-podium{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}.podium{border-radius:16px;padding:18px;display:grid;grid-template-columns:auto 1fr auto;gap:12px;align-items:center}.podium>span{font-size:20px;color:#7796aa}.podium>b{font-size:12px;letter-spacing:.08em}.podium>strong{font-size:24px;color:#8ff2ff}.podium>small{grid-column:2/-1;color:#708fa3;font-size:9px}.podium.rank-1{border-color:rgba(255,180,82,.38)}.podium.rank-1>strong{color:#ffd18e}.lb-table-wrap{border-radius:18px;padding:14px}.lb-table-head{display:flex;justify-content:space-between;padding:3px 4px 12px;border-bottom:1px solid var(--line);color:#7995a7;font-size:9px}.lb-table{display:grid}.lb-row{display:grid;grid-template-columns:56px minmax(220px,1fr) 110px 110px 80px 120px;gap:12px;align-items:center;padding:12px 7px;border-bottom:1px solid rgba(133,200,255,.08);font-size:10px}.lb-row:hover{background:rgba(82,231,255,.025)}.lb-rank{font-size:14px;color:#678699}.lb-id{display:grid;grid-template-columns:10px 1fr;align-items:center;column-gap:8px}.lb-id small{grid-column:2;color:#637f92;font-size:8px;margin-top:3px}.presence{width:7px;height:7px;border-radius:50%;border:1px solid #60798b}.presence.on{background:var(--green);border-color:var(--green);box-shadow:0 0 10px rgba(93,255,189,.6)}.lb-row>strong{font-size:13px;color:#bdefff;font-variant-numeric:tabular-nums}
/* Admin control plane additions */
.adminGrid{grid-template-columns:380px minmax(420px,1fr) 370px}.settings-tabs{display:grid;grid-template-columns:repeat(3,1fr);gap:5px;margin:0 0 12px}.settings-tabs button{padding:7px 4px;font-size:8px}.section-title{font-size:8px;font-weight:800;letter-spacing:.16em;color:#8ea9bb;margin:10px 0 8px;padding-bottom:7px;border-bottom:1px solid rgba(133,200,255,.1)}.control-section>label,.task-control>label{grid-template-columns:1fr 120px}.control-section label.wide{display:block}.control-section label.wide>span{display:block;margin-bottom:5px}.control-section textarea{width:100%;resize:vertical;min-height:64px;border:1px solid rgba(133,200,255,.15);background:rgba(2,8,17,.78);color:var(--text);border-radius:9px;padding:8px;outline:0;font:inherit;font-size:9px}.control-section select{width:120px}.provider-status{display:flex;gap:5px;flex-wrap:wrap;margin:7px 0 12px}.provider-status span{font-size:7px;padding:4px 6px;border-radius:999px;border:1px solid rgba(133,200,255,.13);color:#708b9d}.provider-status span.ready{color:#a8ffda;border-color:rgba(93,255,189,.24);background:rgba(93,255,189,.05)}.provider-status span.off{color:#8b7481;border-color:rgba(255,95,136,.14)}.task-facts{display:grid;grid-template-columns:repeat(3,1fr);gap:5px;margin:7px 0 12px}.task-facts span{border:1px solid rgba(133,200,255,.1);border-radius:9px;padding:7px;color:#728da0;font-size:7px}.task-facts b{display:block;color:#dff8ff;font-size:11px;margin-bottom:3px}.schedule-row{display:grid;grid-template-columns:1fr auto auto;gap:5px}.schedule-row input{min-width:0;width:100%;font-size:8px}.action-history-title{margin-top:16px}.action-list{display:grid;gap:5px}.action-item{display:grid;grid-template-columns:1fr auto auto;gap:7px;align-items:center;padding:7px;border:1px solid rgba(133,200,255,.1);border-radius:9px;background:rgba(255,255,255,.012)}.action-item span{display:grid;min-width:0}.action-item b{font-size:8px;color:#d8f4ff}.action-item small{font-size:7px;color:#668396;margin-top:3px;word-break:break-word}.action-item em{font-style:normal;font-size:7px;text-transform:uppercase;color:#7796aa}.action-item.done em{color:var(--green)}.action-item.error em{color:var(--red)}.action-item.pending em{color:var(--amber)}.action-item button{padding:3px 6px;color:#ff9bad;border-color:rgba(255,95,136,.18)}.action-warning{min-height:12px;color:#ffbe7c;font-size:7px}.tasks>button{grid-template-columns:1.1fr 1.25fr 72px}.tasks .task-state{font-weight:800}.tasks .task-state.active{color:var(--green)}
@media(max-width:1500px){.adminGrid{grid-template-columns:330px minmax(420px,1fr) 340px}.signal-panel{width:300px}.dock{max-width:calc(100vw - 620px)}}
@media(max-width:1180px){.lb-hero{grid-template-columns:1fr}.lb-podium{grid-template-columns:1fr}.lb-row{grid-template-columns:48px minmax(190px,1fr) 90px 90px 60px 90px}.distance-legend{display:none}}
@media(max-width:850px){.leaderboard-page{padding:78px 9px 24px}.leaderboard-top{left:9px;right:9px;top:9px;height:56px;padding:0 12px}.leaderboard-nav a:last-child{display:none}.lb-title h1{font-size:26px}.lb-controls{grid-template-columns:1fr}.lb-podium{display:none}.lb-table-wrap{overflow:auto}.lb-row{min-width:700px}.proximity-mini{display:none}}
/* Keep the task control plane reachable on laptop widths. The earlier admin
breakpoint hid .settings entirely, which made scheduled task actions
inaccessible below 1280px. */
@media(max-width:1280px) and (min-width:1051px){
.adminGrid{grid-template-columns:260px minmax(380px,1fr) 315px}
.settings{display:block}
.tasklist{display:block}
}
@media(max-width:1050px) and (min-width:851px){
.adminGrid{grid-template-columns:minmax(380px,1fr) 315px}
.tasklist{display:none}
.settings{display:block}
}
/* V2.2 mobile mode + public watermarked NFT gallery */
.mobile-quick,.admin-mobile-tabs{display:none}
/* New leaderboard row structure. */
.lb-row{grid-template-columns:56px minmax(220px,1fr) minmax(360px,.9fr) 86px}
.lb-stats{display:grid;grid-template-columns:repeat(4,minmax(72px,1fr));gap:7px}
.lb-stats>span{display:grid;gap:3px;padding:6px 7px;border:1px solid rgba(133,200,255,.08);border-radius:9px;background:rgba(255,255,255,.012)}
.lb-stats small{font-size:7px;color:#668397;letter-spacing:.09em}.lb-stats strong{font-size:12px;color:#bdefff;font-variant-numeric:tabular-nums}
.lb-nft-cell{display:flex;justify-content:center;align-items:center}.lb-nft-button{padding:0;width:66px;height:66px;position:relative;overflow:hidden;border-radius:10px;background:#02060d}
.lb-nft-button img{width:100%;height:100%;display:block;object-fit:cover}.lb-nft-button em{position:absolute;left:3px;right:3px;bottom:3px;padding:2px 3px;border-radius:5px;background:rgba(0,0,0,.67);font-style:normal;font-size:6px;letter-spacing:.07em;color:#fff}
.nft-showcase{display:grid;gap:10px;padding:2px 0 5px}.nft-showcase-head{display:flex;align-items:end;justify-content:space-between;gap:14px}.nft-showcase-head h2{font-size:18px;margin:0}.nft-showcase-head>span{font-size:8px;color:#708da1}.nft-showcase-head>span b{color:#dff8ff}
.nft-gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:10px}.nft-card{padding:8px;text-align:left;display:grid;gap:8px;border-radius:14px;background:rgba(5,13,24,.62)}.nft-card:hover{border-color:rgba(82,231,255,.46)}.nft-card>span:last-child{display:grid;gap:3px;min-width:0}.nft-card b{font-size:9px;color:#def8ff;overflow:hidden;text-overflow:ellipsis}.nft-card small{font-size:7px;color:#6e8a9d;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.nft-image-wrap{position:relative;display:block;aspect-ratio:1/1;overflow:hidden;border-radius:10px;background:radial-gradient(circle,#0b2035,#02050b)}.nft-image-wrap img{display:block;width:100%;height:100%;object-fit:cover}.nft-image-wrap i{position:absolute;right:5px;bottom:5px;padding:3px 5px;border-radius:6px;background:rgba(0,0,0,.66);border:1px solid rgba(255,255,255,.18);font-style:normal;font-size:6px;font-weight:800;letter-spacing:.1em;color:#fff}
.podium{grid-template-columns:66px 1fr auto}.podium-nft{width:58px;height:58px;border-radius:10px;object-fit:cover;border:1px solid rgba(255,255,255,.14)}.podium-rank{font-size:20px;color:#7796aa}.podium>div{display:grid;gap:4px}.podium>div b{font-size:11px}.podium>div small{color:#708fa3;font-size:8px}.podium>strong{font-size:24px;color:#8ff2ff}
.nft-lightbox{position:fixed;z-index:100;inset:0;padding:24px;background:rgba(0,3,8,.86);backdrop-filter:blur(12px);display:grid;place-items:center}.nft-lightbox>button{position:absolute;right:24px;top:20px;width:44px;height:44px;padding:0;font-size:25px;border-radius:50%}.nft-lightbox-card{width:min(760px,94vw);max-height:88vh;overflow:auto;border-radius:18px;padding:12px;display:grid;gap:10px}.nft-lightbox-card img{width:100%;max-height:72vh;object-fit:contain;border-radius:12px;background:#01040a}.nft-lightbox-card>div{display:grid;gap:4px}.nft-lightbox-card b{font-size:11px}.nft-lightbox-card small{font-size:8px;color:#7895a7;letter-spacing:.06em}
/* Explicit MOBILE mode can be enabled on desktop for testing and is enabled
automatically on small/coarse-pointer devices unless the user overrides it. */
html.mobile-mode button,html.mobile-mode .button,html.mobile-mode .dock-link{min-height:42px}
html.mobile-mode .topbar{left:8px;right:8px;top:8px;height:58px;padding:0 10px;grid-template-columns:auto 1fr;gap:8px;border-radius:14px}
html.mobile-mode .topbar .brand strong{font-size:10px;letter-spacing:.1em}.mobile-mode .topbar .brand small,.mobile-mode .mode-status,.mobile-mode .metrics{display:none}
html.mobile-mode .mobile-quick{display:flex;justify-self:end;align-items:center;gap:8px}.mobile-mode .mobile-quick span{display:grid;gap:1px;font-size:6px;color:#66859a;letter-spacing:.09em}.mobile-mode .mobile-quick b{font-size:11px;color:#e6faff}.mobile-mode #guessMobile{display:flex;align-items:center;justify-content:center;min-width:38px;height:28px;border-radius:9px;border:1px solid rgba(93,255,189,.2);color:var(--green);font-size:8px}
html.mobile-mode .distance-legend{display:none}
html.mobile-mode .signal-panel{left:8px;right:8px;top:auto;bottom:112px;width:auto;height:136px;padding:9px;border-radius:14px;transition:height .22s ease;overflow:hidden}
html.mobile-mode .signal-panel:not(.expanded){display:grid;grid-template-columns:105px 1fr;grid-template-rows:28px 1fr;gap:0 8px}
html.mobile-mode .signal-panel:not(.expanded) .panel-title{grid-column:1/-1;height:28px;margin:0}
html.mobile-mode .signal-panel:not(.expanded) .rank-hero{padding:7px 4px 0;border:0}.mobile-mode .signal-panel:not(.expanded) .rank-hero strong{font-size:28px}
html.mobile-mode .signal-panel:not(.expanded) .signal-metrics{padding:5px 0;border:0;gap:4px}.mobile-mode .signal-panel:not(.expanded) .signal-metrics div{padding:5px}.mobile-mode .signal-panel:not(.expanded) .signal-metrics span{font-size:6px}.mobile-mode .signal-panel:not(.expanded) .signal-metrics b{font-size:12px;margin-top:3px}
html.mobile-mode .signal-panel:not(.expanded) .proximity-mini,html.mobile-mode .signal-panel:not(.expanded) .identity-block,html.mobile-mode .signal-panel:not(.expanded)>.leaderboard-head,html.mobile-mode .signal-panel:not(.expanded)>.leaderboard{display:none}
html.mobile-mode .signal-panel.expanded{height:min(58vh,520px);display:flex}.mobile-mode .signal-panel.expanded .rank-hero strong{font-size:32px}.mobile-mode .signal-panel.expanded .identity-block code{max-height:34px}.mobile-mode .signal-panel.expanded .leaderboard{min-height:90px}
html.mobile-mode .node-limit{left:8px;right:8px;bottom:64px;width:auto;height:42px;padding:6px 9px;border-radius:12px;grid-template-columns:76px 1fr 48px}.mobile-mode .node-limit span{font-size:7px}.mobile-mode .node-limit b{text-align:right}
html.mobile-mode .dock{left:8px;right:8px;bottom:8px;transform:none;max-width:none;width:auto;height:50px;justify-content:space-between;gap:4px;padding:4px;border-radius:13px;overflow:hidden}.mobile-mode .dock button,.mobile-mode .dock-link{padding:7px 8px;font-size:7px;min-width:0;flex:1;text-align:center}.mobile-mode #toggleRotate,.mobile-mode #toggleLabels,.mobile-mode #toggleEdges,.mobile-mode #toggleShells,.mobile-mode #toggleLOD,.mobile-mode #toggleEco,.mobile-mode .dock-link[href="/admin"]{display:none}.mobile-mode #toggleMobile,.mobile-mode #toggleDetails,.mobile-mode #toggleProximity,.mobile-mode #resetView,.mobile-mode .dock-link[href="/leaderboard"]{display:block}
/* Leaderboard mobile mode uses cards instead of a horizontally scrolling table. */
html.mobile-mode .leaderboard-page{padding:76px 8px 28px}.mobile-mode .leaderboard-top{left:8px;right:8px;top:8px;height:58px;padding:0 10px}.mobile-mode .leaderboard-top .brand strong{font-size:9px;letter-spacing:.09em}.mobile-mode .leaderboard-top .brand small{display:none}.mobile-mode .leaderboard-nav{gap:4px}.mobile-mode .leaderboard-nav a,.mobile-mode .leaderboard-nav button{font-size:7px;padding:6px 7px;min-height:38px}.mobile-mode .leaderboard-nav a:last-child{display:none}.mobile-mode .lb-main{gap:13px}.mobile-mode .lb-hero{grid-template-columns:1fr;padding:8px 2px;gap:11px}.mobile-mode .lb-title h1{font-size:24px}.mobile-mode .lb-title p{font-size:10px}.mobile-mode .lb-controls{grid-template-columns:1fr;gap:7px}.mobile-mode .lb-podium{display:grid;grid-template-columns:1fr;gap:7px}.mobile-mode .podium{padding:9px;grid-template-columns:48px 1fr auto}.mobile-mode .podium-nft{width:44px;height:44px}.mobile-mode .podium>strong{font-size:17px}.mobile-mode .nft-showcase-head{align-items:start}.mobile-mode .nft-showcase-head h2{font-size:16px}.mobile-mode .nft-showcase-head>span{max-width:140px;text-align:right}.mobile-mode .nft-gallery{grid-template-columns:repeat(2,minmax(0,1fr));gap:7px}.mobile-mode .nft-card{padding:6px}.mobile-mode .lb-table-wrap{padding:7px;overflow:visible}.mobile-mode .lb-table-head span:last-child{display:none}.mobile-mode .lb-row{min-width:0;grid-template-columns:38px minmax(0,1fr) 62px;gap:7px;padding:9px 3px}.mobile-mode .lb-id{min-width:0}.mobile-mode .lb-id b{overflow:hidden;text-overflow:ellipsis}.mobile-mode .lb-stats{grid-column:2/4;grid-row:2;grid-template-columns:repeat(4,1fr);gap:4px}.mobile-mode .lb-stats>span{padding:5px 4px}.mobile-mode .lb-stats small{font-size:6px}.mobile-mode .lb-stats strong{font-size:10px}.mobile-mode .lb-nft-cell{grid-column:3;grid-row:1}.mobile-mode .lb-nft-button{width:56px;height:56px}.mobile-mode .nft-lightbox{padding:10px}.mobile-mode .nft-lightbox>button{right:10px;top:10px}.mobile-mode .nft-lightbox-card{width:100%;padding:8px}
/* Admin mobile navigation keeps all three major admin surfaces accessible. */
html.mobile-mode .admin-top{left:8px;right:8px;top:8px;height:56px;padding:0 10px}.mobile-mode .admin-top .brand strong{font-size:9px;letter-spacing:.08em}.mobile-mode .admin-top .brand small{display:none}.mobile-mode .admin-actions{gap:4px}.mobile-mode .admin-actions a{display:none}.mobile-mode .admin-actions button{font-size:7px;padding:6px 7px;min-height:38px}
html.mobile-mode .overviewStrip{display:none}.mobile-mode .admin-mobile-tabs{position:fixed;z-index:9;left:8px;right:8px;top:72px;height:44px;padding:4px;display:grid;grid-template-columns:repeat(3,1fr);gap:4px;border-radius:12px}.mobile-mode .admin-mobile-tabs button{padding:5px;font-size:8px;min-height:34px}.mobile-mode .admin-mobile-tabs button.active{color:var(--cyan);background:rgba(82,231,255,.08);border-color:rgba(82,231,255,.32)}
html.mobile-mode .adminGrid{position:fixed;left:8px;right:8px;top:124px;bottom:8px;display:block;overflow:hidden}.mobile-mode .adminGrid>section{display:none;width:100%;height:100%;min-height:0}.mobile-mode .adminGrid.mobile-show-map>.adminMap{display:block}.mobile-mode .adminGrid.mobile-show-tasks>.tasklist{display:block;overflow:auto}.mobile-mode .adminGrid.mobile-show-control>.settings{display:block;overflow:auto}.mobile-mode .tasklist,.mobile-mode .settings,.mobile-mode .adminMap{border-radius:14px}.mobile-mode .toolbar{grid-template-columns:94px 1fr}.mobile-mode .toolbar button{grid-column:1/-1}.mobile-mode .tasks>button{grid-template-columns:1fr;gap:5px}.mobile-mode .tasks>button>span:last-child{display:flex;justify-content:space-between;align-items:center}.mobile-mode .map-overlay{left:7px;right:7px;padding:6px;gap:7px;font-size:7px;overflow:auto}.mobile-mode .map-overlay.top{top:7px;flex-wrap:wrap;max-height:90px}.mobile-mode .map-overlay.top>span:nth-child(4),.mobile-mode .map-overlay.top>span:nth-child(5){display:none}.mobile-mode .map-overlay.top .inline-filter{margin-left:0}.mobile-mode .map-overlay.bottom{bottom:7px;height:48px}.mobile-mode .map-overlay.bottom label{min-width:185px}.mobile-mode .map-overlay.bottom #adminRotate,.mobile-mode .map-overlay.bottom #adminEdges,.mobile-mode .map-overlay.bottom #adminShells,.mobile-mode .map-overlay.bottom #adminLOD,.mobile-mode .map-overlay.bottom #adminEco{display:none}.mobile-mode .settings label{grid-template-columns:1fr 105px;gap:6px}.mobile-mode .control-section>label,.mobile-mode .task-control>label{grid-template-columns:1fr 105px}.mobile-mode .schedule-row{grid-template-columns:1fr 1fr}.mobile-mode .schedule-row input{grid-column:1/-1}.mobile-mode .action-item{grid-template-columns:1fr auto}.mobile-mode .action-item button{grid-column:2}.mobile-mode .task-facts{grid-template-columns:repeat(3,1fr)}
@media(max-width:520px){html.mobile-mode .nft-gallery{grid-template-columns:1fr 1fr}.mobile-mode .mark{width:28px;height:28px;transform:scale(.82)}.mobile-mode .brand{gap:6px}.mobile-mode .leaderboard-nav a[href="/admin"]{display:none}.mobile-mode .lb-title p{line-height:1.45}.mobile-mode .signal-panel.expanded{height:min(64vh,540px)}}
.artifactLinks [data-artifact-task],.artifactLinks [data-manifest-task]{color:var(--cyan);cursor:pointer;text-decoration:none}.artifactLinks [data-artifact-task]:hover,.artifactLinks [data-manifest-task]:hover{text-decoration:underline}
/* V2.5 task chooser: task selection is a first-class client surface rather
than an implicit hash assignment. It deliberately reuses the neural visual
language while keeping the decision readable on desktop and mobile. */
.task-landing{position:fixed;z-index:40;inset:0;display:none;overflow:auto;background:rgba(1,4,10,.90);backdrop-filter:blur(18px)}.task-landing.visible{display:block}.task-landing-bg{position:fixed;inset:0;pointer-events:none;background:radial-gradient(circle at 22% 20%,rgba(82,231,255,.09),transparent 32%),radial-gradient(circle at 82% 65%,rgba(183,117,255,.08),transparent 34%),linear-gradient(rgba(82,231,255,.025) 1px,transparent 1px),linear-gradient(90deg,rgba(82,231,255,.018) 1px,transparent 1px);background-size:auto,auto,54px 54px,54px 54px;mask-image:linear-gradient(to bottom,#000 0,rgba(0,0,0,.85) 70%,transparent)}.task-landing-inner{position:relative;width:min(1180px,calc(100% - 48px));margin:0 auto;padding:116px 0 48px}.task-landing-head{display:grid;grid-template-columns:minmax(0,1fr) 310px;gap:28px;align-items:end;margin-bottom:26px}.task-landing-head h1{font-size:clamp(34px,5vw,66px);line-height:.98;letter-spacing:-.05em;margin:5px 0 13px;max-width:720px}.task-landing-head p{max-width:720px;color:#89a6b8;font-size:13px;line-height:1.65;margin:0}.task-landing-id{border-radius:17px;padding:14px;display:grid;gap:8px}.task-landing-id span{font-size:8px;letter-spacing:.16em;color:#718fa3;font-weight:800}.task-landing-id code{font-size:9px;color:#9cecff;word-break:break-all;max-height:38px;overflow:hidden}.task-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(270px,1fr));gap:13px}.task-card{position:relative;border-radius:20px;padding:16px;min-height:300px;display:flex;flex-direction:column;overflow:hidden;transition:transform .2s ease,border-color .2s ease,background .2s ease}.task-card:before{content:"";position:absolute;inset:auto -40px -80px auto;width:180px;height:180px;border:1px solid rgba(82,231,255,.13);border-radius:50%;box-shadow:0 0 60px rgba(82,231,255,.04)}.task-card:hover{transform:translateY(-3px);border-color:rgba(82,231,255,.34)}.task-card.selected{border-color:rgba(93,255,189,.42);background:linear-gradient(145deg,rgba(8,29,37,.9),rgba(3,10,18,.72))}.task-card.paused{filter:saturate(.65)}.task-card-top{display:grid;grid-template-columns:36px minmax(0,1fr) auto;gap:11px;align-items:start}.task-orbit{width:34px;height:34px;border-radius:50%;border:1px solid rgba(82,231,255,.28);display:grid;place-items:center;font-size:9px;color:#8deaff;box-shadow:inset 0 0 20px rgba(82,231,255,.05)}.task-card h2{font-size:18px;line-height:1.08;margin:0 0 5px;letter-spacing:-.02em}.task-card code{font-size:7px;color:#66899f}.task-bit{font-size:25px;line-height:.8;color:#dffaff;text-align:right}.task-bit small{display:block;font-size:6px;color:#648398;letter-spacing:.12em;margin-top:8px}.task-style-preview{position:relative;height:112px;margin:14px 0 0;border:1px solid rgba(133,200,255,.1);border-radius:12px;overflow:hidden;background:rgba(2,8,17,.65)}.task-style-preview img{width:100%;height:100%;display:block;object-fit:cover;filter:saturate(.9) contrast(1.03)}.task-style-preview:after{content:"";position:absolute;inset:0;background:linear-gradient(to bottom,transparent 45%,rgba(1,5,11,.68))}.task-style-preview span{position:absolute;z-index:1;left:8px;bottom:7px;font-size:6px;letter-spacing:.12em;font-weight:800;color:#d9f8ff;padding:4px 6px;border-radius:999px;background:rgba(2,8,17,.72);border:1px solid rgba(133,200,255,.14)}.task-card>p{color:#7e9bad;font-size:10px;line-height:1.55;min-height:48px;margin:12px 0 17px}.task-card-stats{display:grid;grid-template-columns:repeat(4,1fr);gap:5px;margin-top:auto}.task-card-stats span{border:1px solid rgba(133,200,255,.09);background:rgba(255,255,255,.018);border-radius:9px;padding:7px}.task-card-stats small{display:block;font-size:6px;color:#607d91;letter-spacing:.08em}.task-card-stats b{display:block;margin-top:4px;font-size:10px}.task-card>button{position:relative;margin-top:12px;width:100%;display:flex;justify-content:space-between;align-items:center;padding:10px 12px}.task-card.selected>button{color:#baffde;border-color:rgba(93,255,189,.28);background:rgba(93,255,189,.06)}.task-card-empty,.task-card-loading{grid-column:1/-1;min-height:180px;border-radius:18px;display:grid;place-items:center;text-align:center;color:#7f9cad;padding:30px}.task-card-empty b{display:block;color:#dff8ff;margin-bottom:6px}.task-landing-foot{display:flex;justify-content:space-between;gap:18px;align-items:center;margin-top:18px;padding:12px 3px;color:#66869a;font-size:9px}.task-landing-foot a{color:#8feeff;text-decoration:none;font-weight:800;letter-spacing:.08em}
.task-config-box{border:1px solid rgba(82,231,255,.13);border-radius:12px;padding:10px;margin-bottom:12px;background:rgba(82,231,255,.025)}.task-config-box .section-title{margin-top:0}.task-config-box textarea{width:100%;resize:vertical;min-height:76px;border:1px solid rgba(133,200,255,.15);background:rgba(2,8,17,.78);color:var(--text);border-radius:9px;padding:8px;outline:0;font:inherit;font-size:9px;line-height:1.45}.task-config-box input{width:100%}.task-config-actions{display:flex;gap:7px;justify-content:flex-end;margin-top:8px}.draft-note{font-size:7px;color:#7ea7b9;margin:4px 0 9px}.inherit-badge{display:inline-flex;align-items:center;gap:5px;padding:4px 7px;border:1px solid rgba(93,255,189,.16);border-radius:999px;color:#8edbb8;font-size:7px;letter-spacing:.05em}
html.mobile-mode .task-landing-inner{width:calc(100% - 16px);padding:82px 0 22px}.mobile-mode .task-landing-head{grid-template-columns:1fr;gap:12px;margin-bottom:14px}.mobile-mode .task-landing-head h1{font-size:32px}.mobile-mode .task-landing-head p{font-size:10px}.mobile-mode .task-landing-id{padding:10px}.mobile-mode .task-cards{grid-template-columns:1fr;gap:8px}.mobile-mode .task-card{min-height:0;padding:12px}.mobile-mode .task-card>p{min-height:0;margin:12px 0}.mobile-mode .task-card-stats{grid-template-columns:repeat(4,1fr)}.mobile-mode .task-card-stats span{padding:6px 4px}.mobile-mode .task-landing-foot{align-items:flex-start;flex-direction:column}.mobile-mode #chooseTask{display:block}.mobile-mode .dock #toggleMobile{display:none}
/* RIFT OpenAI usage/cost telemetry. */
.cost-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:7px;margin:7px 0 9px}.cost-card{border:1px solid rgba(82,231,255,.13);background:rgba(82,231,255,.025);border-radius:11px;padding:10px;min-width:0}.cost-card small{display:block;color:#668ba0;font-size:6px;letter-spacing:.12em;font-weight:800}.cost-card b{display:block;color:#dffaff;font-size:17px;line-height:1.05;margin:7px 0 5px;letter-spacing:-.03em}.cost-card span{display:block;color:#7898aa;font-size:7px;line-height:1.35}.usage-note{border-left:2px solid rgba(82,231,255,.3);padding:6px 8px;margin:7px 0 9px;color:#7194a8;font-size:7px;line-height:1.45}.usage-table-wrap{width:100%;overflow:auto;border:1px solid rgba(133,200,255,.1);border-radius:10px;margin-bottom:10px}.usage-table{width:100%;min-width:690px;border-collapse:collapse;font-size:7px}.usage-table th,.usage-table td{text-align:left;padding:7px 8px;border-bottom:1px solid rgba(133,200,255,.07);vertical-align:top;white-space:nowrap}.usage-table th{position:sticky;top:0;background:rgba(3,10,18,.96);color:#6f91a6;font-size:6px;letter-spacing:.08em}.usage-table td{color:#a6bfcd}.usage-table td small{display:block;color:#617f92;margin-top:2px}.usage-table tr:last-child td{border-bottom:0}
html.mobile-mode .cost-grid{grid-template-columns:1fr}.mobile-mode .cost-card{padding:8px}.mobile-mode .usage-table-wrap{max-width:100%}
.reference-admin-box,.task-style-admin{display:grid;grid-template-columns:minmax(0,1fr) 128px;gap:10px;align-items:stretch;border:1px solid rgba(82,231,255,.13);border-radius:12px;padding:10px;margin:10px 0;background:rgba(82,231,255,.025)}.reference-preview{min-height:128px;border:1px dashed rgba(133,200,255,.18);border-radius:10px;display:grid;place-items:center;overflow:hidden;background:rgba(2,8,17,.72);color:#607f92;font-size:7px;letter-spacing:.12em}.reference-preview.has-image{border-style:solid}.reference-preview img{width:100%;height:100%;min-height:128px;display:block;object-fit:cover}.ready-copy{color:#8edbb8}.anchor-actions{margin:0 0 12px}.task-style-admin{grid-template-columns:128px minmax(0,1fr);margin:10px 0 12px}.task-style-controls{min-width:0}.task-style-controls .section-title{margin-top:0}.task-style-controls input[type=file]{width:100%;font-size:7px;color:#7898aa;border:1px solid rgba(133,200,255,.12);border-radius:8px;padding:6px;background:rgba(2,8,17,.55)}.style-actions{justify-content:flex-start;flex-wrap:wrap}.danger-button{color:#ff9bad!important;border-color:rgba(255,95,136,.22)!important;background:rgba(255,95,136,.04)!important}.control-section button:disabled{opacity:.45;cursor:not-allowed}
html.mobile-mode .reference-admin-box,html.mobile-mode .task-style-admin{grid-template-columns:1fr}.mobile-mode .reference-preview{min-height:160px}.mobile-mode .reference-preview img{min-height:160px;max-height:240px}
+10
View File
@@ -0,0 +1,10 @@
package webui
import "embed"
// Dist contains the production frontend assets. They are compiled into the Go
// binary, so running Neural Hunt does not require Node.js, npm, Vite, or a
// separate static-file directory.
//
//go:embed dist/*
var Dist embed.FS
+256
View File
@@ -0,0 +1,256 @@
package ws
import (
"context"
"encoding/json"
"sync"
"sync/atomic"
"time"
"github.com/gorilla/websocket"
)
type Event struct {
Type string `json:"type"`
TaskID string `json:"task_id,omitempty"`
Data any `json:"data,omitempty"`
}
type Client struct {
Conn *websocket.Conn
TaskID string
ClientID string
All bool
send chan []byte
closed chan struct{}
onWrite func(int)
onDrop func()
closeOnce sync.Once
}
func NewClient(conn *websocket.Conn, taskID, clientID string, all bool) *Client {
return &Client{Conn: conn, TaskID: taskID, ClientID: clientID, All: all, send: make(chan []byte, 32), closed: make(chan struct{})}
}
func (c *Client) start() { go c.writeLoop() }
func (c *Client) writeLoop() {
for {
select {
case <-c.closed:
return
case b := <-c.send:
_ = c.Conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
if err := c.Conn.WriteMessage(websocket.TextMessage, b); err != nil {
c.Close()
return
}
if c.onWrite != nil {
c.onWrite(len(b))
}
}
}
}
func (c *Client) Enqueue(v any) bool {
b, err := json.Marshal(v)
if err != nil {
return false
}
return c.EnqueueBytes(b)
}
func (c *Client) EnqueueBytes(b []byte) bool {
select {
case <-c.closed:
return false
default:
}
select {
case c.send <- b:
return true
default:
if c.onDrop != nil {
c.onDrop()
}
return false
}
}
func (c *Client) Close() { c.closeOnce.Do(func() { close(c.closed); _ = c.Conn.Close() }) }
// Ping sends a real WebSocket control ping. Browsers answer this automatically
// with a pong, which refreshes the server-side read deadline. Do not replace
// this with an application JSON message: that caused every otherwise-idle
// browser connection to be closed after the 90s read deadline.
func (c *Client) Ping() error {
select {
case <-c.closed:
return context.Canceled
default:
}
if err := c.Conn.WriteControl(websocket.PingMessage, []byte("nh"), time.Now().Add(5*time.Second)); err != nil {
c.Close()
return err
}
return nil
}
type pointUpdate struct {
ClientID string
Data any
}
type rateBucket struct {
unix int64
frames, bytes, drops uint64
}
type Hub struct {
mu sync.RWMutex
clients map[*Client]struct{}
pendingMu sync.Mutex
pending map[string]map[string]any
frames atomic.Uint64
bytes atomic.Uint64
drops atomic.Uint64
rateMu sync.Mutex
rate [60]rateBucket
}
func New() *Hub { return &Hub{clients: map[*Client]struct{}{}, pending: map[string]map[string]any{}} }
func (h *Hub) Add(c *Client) {
c.onWrite = h.recordWrite
c.onDrop = h.recordDrop
// Same-session websocket reconnects are intentionally allowed by runtime.
// Keep only the newest user socket for a ClientID so a half-dead TCP
// connection cannot continue receiving frames beside its replacement.
var stale []*Client
h.mu.Lock()
if !c.All && c.ClientID != "" {
for old := range h.clients {
if !old.All && old.ClientID == c.ClientID {
delete(h.clients, old)
stale = append(stale, old)
}
}
}
h.clients[c] = struct{}{}
h.mu.Unlock()
for _, old := range stale {
old.Close()
}
c.start()
}
func (h *Hub) Remove(c *Client) { h.mu.Lock(); delete(h.clients, c); h.mu.Unlock(); c.Close() }
func (h *Hub) Connected() int { h.mu.RLock(); defer h.mu.RUnlock(); return len(h.clients) }
func (h *Hub) Publish(ctx context.Context, e Event) error {
if err := ctx.Err(); err != nil {
return err
}
b, err := json.Marshal(e)
if err != nil {
return err
}
h.broadcastBytes(e.TaskID, b)
return nil
}
// PublishPoint coalesces repeated improvements for the same client. Every 250ms
// all changed points for a task are emitted as one frame per websocket instead
// of one synchronous write per guess per client.
func (h *Hub) PublishPoint(taskID, clientID string, data any) {
h.pendingMu.Lock()
m := h.pending[taskID]
if m == nil {
m = map[string]any{}
h.pending[taskID] = m
}
m[clientID] = data
h.pendingMu.Unlock()
}
func (h *Hub) flushPoints() {
h.pendingMu.Lock()
pending := h.pending
h.pending = map[string]map[string]any{}
h.pendingMu.Unlock()
for taskID, m := range pending {
arr := make([]any, 0, len(m))
for _, v := range m {
arr = append(arr, v)
}
b, err := json.Marshal(Event{Type: "points", TaskID: taskID, Data: arr})
if err == nil {
h.broadcastBytes(taskID, b)
}
}
}
func (h *Hub) broadcastBytes(taskID string, b []byte) {
h.mu.RLock()
clients := make([]*Client, 0, len(h.clients))
for c := range h.clients {
if c.All || taskID == "" || c.TaskID == taskID {
clients = append(clients, c)
}
}
h.mu.RUnlock()
for _, c := range clients {
c.EnqueueBytes(b)
}
}
func (h *Hub) recordWrite(n int) {
h.frames.Add(1)
h.bytes.Add(uint64(n))
now := time.Now().Unix()
h.rateMu.Lock()
i := now % 60
if h.rate[i].unix != now {
h.rate[i] = rateBucket{unix: now}
}
h.rate[i].frames++
h.rate[i].bytes += uint64(n)
h.rateMu.Unlock()
}
func (h *Hub) recordDrop() {
h.drops.Add(1)
now := time.Now().Unix()
h.rateMu.Lock()
i := now % 60
if h.rate[i].unix != now {
h.rate[i] = rateBucket{unix: now}
}
h.rate[i].drops++
h.rateMu.Unlock()
}
type Metrics struct {
Connected int `json:"connected"`
FramesPerSec float64 `json:"frames_per_sec"`
BytesPerSec float64 `json:"bytes_per_sec"`
DroppedPerSec float64 `json:"dropped_per_sec"`
FramesTotal uint64 `json:"frames_total"`
BytesTotal uint64 `json:"bytes_total"`
DroppedTotal uint64 `json:"dropped_total"`
}
func (h *Hub) Metrics() Metrics {
now := time.Now().Unix()
var f, b, d uint64
h.rateMu.Lock()
for _, x := range h.rate {
if x.unix > now-5 {
f += x.frames
b += x.bytes
d += x.drops
}
}
h.rateMu.Unlock()
return Metrics{Connected: h.Connected(), FramesPerSec: float64(f) / 5, BytesPerSec: float64(b) / 5, DroppedPerSec: float64(d) / 5, FramesTotal: h.frames.Load(), BytesTotal: h.bytes.Load(), DroppedTotal: h.drops.Load()}
}
func (h *Hub) Run(ctx context.Context) {
t := time.NewTicker(250 * time.Millisecond)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
h.flushPoints()
}
}
}