All checks were successful
release-tag / release-image (push) Successful in 3m57s
399 lines
17 KiB
Go
399 lines
17 KiB
Go
package settings
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
type Runtime struct {
|
|
GuessMinIntervalSec int `json:"guess_min_interval_sec"`
|
|
ClientSubmitIntervalSec int `json:"client_submit_interval_sec"`
|
|
GuessLotteryWindowSec int `json:"guess_lottery_window_sec"`
|
|
GuessLotteryMaxAccepted int `json:"guess_lottery_max_accepted"`
|
|
BeaconHuntEnabled int `json:"beacon_hunt_enabled"`
|
|
BeaconBonusWeight int `json:"beacon_bonus_weight"`
|
|
SybilProofOfWorkBits int `json:"sybil_pow_bits"`
|
|
SybilWarmupSec int `json:"sybil_warmup_sec"`
|
|
OpenAIMaxCalls1H int `json:"openai_max_calls_1h"`
|
|
OpenAIMaxCalls24H int `json:"openai_max_calls_24h"`
|
|
OpenAIMaxCost24HUSD float64 `json:"openai_max_cost_24h_usd"`
|
|
OpenAIBudgetReserveUSD float64 `json:"openai_budget_reserve_usd"`
|
|
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"`
|
|
PlaceEnabled int `json:"place_enabled"`
|
|
PlaceWidth int `json:"place_width"`
|
|
PlaceHeight int `json:"place_height"`
|
|
PlacePointsPerScore int `json:"place_points_per_score"`
|
|
PlaceDrawPoints int `json:"place_draw_points"`
|
|
PlaceDrawBeaconMultiplier int `json:"place_draw_beacon_multiplier"`
|
|
PlaceTimePoints int `json:"place_time_points"`
|
|
PlaceTimeIntervalSec int `json:"place_time_interval_sec"`
|
|
PlaceTimeMaxGapSec int `json:"place_time_max_gap_sec"`
|
|
PlacePixelCost int `json:"place_pixel_cost"`
|
|
ArtifactPreset string `json:"artifact_preset"`
|
|
ArtifactProvider string `json:"artifact_provider"`
|
|
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"`
|
|
ArtifactRarityCommonPct float64 `json:"artifact_rarity_common_pct"`
|
|
ArtifactRarityUncommonPct float64 `json:"artifact_rarity_uncommon_pct"`
|
|
ArtifactRarityRarePct float64 `json:"artifact_rarity_rare_pct"`
|
|
ArtifactRarityUltraRarePct float64 `json:"artifact_rarity_ultra_rare_pct"`
|
|
ArtifactRaritySpecialIllustrationPct float64 `json:"artifact_rarity_special_illustration_pct"`
|
|
}
|
|
|
|
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 envFloat(k string, def float64) float64 {
|
|
if s := strings.TrimSpace(os.Getenv(k)); s != "" {
|
|
if n, err := strconv.ParseFloat(s, 64); err == nil {
|
|
return n
|
|
}
|
|
}
|
|
return def
|
|
}
|
|
|
|
func Defaults() Runtime {
|
|
return Runtime{
|
|
GuessMinIntervalSec: envInt("DEFAULT_GUESS_MIN_INTERVAL_SEC", 10),
|
|
ClientSubmitIntervalSec: envInt("DEFAULT_CLIENT_SUBMIT_INTERVAL_SEC", 11),
|
|
GuessLotteryWindowSec: envInt("DEFAULT_GUESS_LOTTERY_WINDOW_SEC", 60),
|
|
GuessLotteryMaxAccepted: envInt("DEFAULT_GUESS_LOTTERY_MAX_ACCEPTED", 0),
|
|
BeaconHuntEnabled: envInt("DEFAULT_BEACON_HUNT_ENABLED", 0),
|
|
BeaconBonusWeight: envInt("DEFAULT_BEACON_BONUS_WEIGHT", 2),
|
|
SybilProofOfWorkBits: envInt("DEFAULT_SYBIL_POW_BITS", 15),
|
|
SybilWarmupSec: envInt("DEFAULT_SYBIL_WARMUP_SEC", 15),
|
|
OpenAIMaxCalls1H: envInt("DEFAULT_OPENAI_MAX_CALLS_1H", 20),
|
|
OpenAIMaxCalls24H: envInt("DEFAULT_OPENAI_MAX_CALLS_24H", 100),
|
|
OpenAIMaxCost24HUSD: envFloat("DEFAULT_OPENAI_MAX_COST_24H_USD", 5.00),
|
|
OpenAIBudgetReserveUSD: envFloat("DEFAULT_OPENAI_BUDGET_RESERVE_USD", 0.20),
|
|
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),
|
|
PlaceEnabled: envInt("DEFAULT_PLACE_ENABLED", 1),
|
|
PlaceWidth: envInt("DEFAULT_PLACE_WIDTH", 256),
|
|
PlaceHeight: envInt("DEFAULT_PLACE_HEIGHT", 256),
|
|
PlacePointsPerScore: envInt("DEFAULT_PLACE_POINTS_PER_SCORE", 100),
|
|
PlaceDrawPoints: envInt("DEFAULT_PLACE_DRAW_POINTS", 25),
|
|
PlaceDrawBeaconMultiplier: envInt("DEFAULT_PLACE_DRAW_BEACON_MULTIPLIER", 1),
|
|
PlaceTimePoints: envInt("DEFAULT_PLACE_TIME_POINTS", 20),
|
|
PlaceTimeIntervalSec: envInt("DEFAULT_PLACE_TIME_INTERVAL_SEC", 60),
|
|
PlaceTimeMaxGapSec: envInt("DEFAULT_PLACE_TIME_MAX_GAP_SEC", 120),
|
|
PlacePixelCost: envInt("DEFAULT_PLACE_PIXEL_COST", 100),
|
|
ArtifactPreset: strings.ToLower(envString("ARTIFACT_PRESET", "raccoon_full_art_v1")),
|
|
ArtifactProvider: strings.ToLower(envString("ARTIFACT_PROVIDER", "openai")),
|
|
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")),
|
|
ArtifactRarityCommonPct: envFloat("DEFAULT_ARTIFACT_RARITY_COMMON_PCT", 58.00),
|
|
ArtifactRarityUncommonPct: envFloat("DEFAULT_ARTIFACT_RARITY_UNCOMMON_PCT", 29.50),
|
|
ArtifactRarityRarePct: envFloat("DEFAULT_ARTIFACT_RARITY_RARE_PCT", 9.90),
|
|
ArtifactRarityUltraRarePct: envFloat("DEFAULT_ARTIFACT_RARITY_ULTRA_RARE_PCT", 2.25),
|
|
ArtifactRaritySpecialIllustrationPct: envFloat("DEFAULT_ARTIFACT_RARITY_SPECIAL_ILLUSTRATION_PCT", 0.35),
|
|
}
|
|
}
|
|
|
|
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.GuessLotteryWindowSec < 1 || v.GuessLotteryWindowSec > 3600 {
|
|
return fmt.Errorf("guess_lottery_window_sec must be 1..3600")
|
|
}
|
|
if v.GuessLotteryMaxAccepted < 0 || v.GuessLotteryMaxAccepted > 100000 {
|
|
return fmt.Errorf("guess_lottery_max_accepted must be 0..100000 (0 disables lottery)")
|
|
}
|
|
if v.BeaconHuntEnabled < 0 || v.BeaconHuntEnabled > 1 {
|
|
return fmt.Errorf("beacon_hunt_enabled must be 0 or 1")
|
|
}
|
|
if v.BeaconBonusWeight < 1 || v.BeaconBonusWeight > 10 {
|
|
return fmt.Errorf("beacon_bonus_weight must be 1..10")
|
|
}
|
|
if v.SybilProofOfWorkBits < 0 || v.SybilProofOfWorkBits > 22 {
|
|
return fmt.Errorf("sybil_pow_bits must be 0..22")
|
|
}
|
|
if v.SybilWarmupSec < 0 || v.SybilWarmupSec > 86400 {
|
|
return fmt.Errorf("sybil_warmup_sec must be 0..86400")
|
|
}
|
|
if v.OpenAIMaxCalls1H < 0 || v.OpenAIMaxCalls1H > 100000 {
|
|
return fmt.Errorf("openai_max_calls_1h must be 0..100000")
|
|
}
|
|
if v.OpenAIMaxCalls24H < 0 || v.OpenAIMaxCalls24H > 1000000 {
|
|
return fmt.Errorf("openai_max_calls_24h must be 0..1000000")
|
|
}
|
|
if v.OpenAIMaxCost24HUSD < 0 || v.OpenAIMaxCost24HUSD > 100000 {
|
|
return fmt.Errorf("openai_max_cost_24h_usd must be 0..100000")
|
|
}
|
|
if v.OpenAIBudgetReserveUSD < 0 || v.OpenAIBudgetReserveUSD > 10000 {
|
|
return fmt.Errorf("openai_budget_reserve_usd must be 0..10000")
|
|
}
|
|
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")
|
|
}
|
|
if v.PlaceEnabled < 0 || v.PlaceEnabled > 1 {
|
|
return fmt.Errorf("place_enabled must be 0 or 1")
|
|
}
|
|
if v.PlaceWidth < 32 || v.PlaceWidth > 1024 || v.PlaceHeight < 32 || v.PlaceHeight > 1024 {
|
|
return fmt.Errorf("place dimensions must be 32..1024")
|
|
}
|
|
if v.PlacePointsPerScore < 1 || v.PlacePointsPerScore > 1000000 {
|
|
return fmt.Errorf("place_points_per_score must be 1..1000000")
|
|
}
|
|
if v.PlaceDrawPoints < 0 || v.PlaceDrawPoints > 100000000 {
|
|
return fmt.Errorf("place_draw_points must be 0..100000000")
|
|
}
|
|
if v.PlaceDrawBeaconMultiplier < 0 || v.PlaceDrawBeaconMultiplier > 1 {
|
|
return fmt.Errorf("place_draw_beacon_multiplier must be 0 or 1")
|
|
}
|
|
if v.PlaceTimePoints < 0 || v.PlaceTimePoints > 100000000 {
|
|
return fmt.Errorf("place_time_points must be 0..100000000")
|
|
}
|
|
if v.PlaceTimeIntervalSec < 5 || v.PlaceTimeIntervalSec > 86400 {
|
|
return fmt.Errorf("place_time_interval_sec must be 5..86400")
|
|
}
|
|
if v.PlaceTimeMaxGapSec < 5 || v.PlaceTimeMaxGapSec > 86400 {
|
|
return fmt.Errorf("place_time_max_gap_sec must be 5..86400")
|
|
}
|
|
if v.PlacePixelCost < 1 || v.PlacePixelCost > 100000000 {
|
|
return fmt.Errorf("place_pixel_cost must be 1..100000000")
|
|
}
|
|
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")
|
|
}
|
|
rarityValues := []struct {
|
|
name string
|
|
v float64
|
|
}{
|
|
{"artifact_rarity_common_pct", v.ArtifactRarityCommonPct},
|
|
{"artifact_rarity_uncommon_pct", v.ArtifactRarityUncommonPct},
|
|
{"artifact_rarity_rare_pct", v.ArtifactRarityRarePct},
|
|
{"artifact_rarity_ultra_rare_pct", v.ArtifactRarityUltraRarePct},
|
|
{"artifact_rarity_special_illustration_pct", v.ArtifactRaritySpecialIllustrationPct},
|
|
}
|
|
total := 0.0
|
|
for _, x := range rarityValues {
|
|
if x.v < 0 || x.v > 100 {
|
|
return fmt.Errorf("%s must be 0..100", x.name)
|
|
}
|
|
total += x.v
|
|
}
|
|
if math.Abs(total-100.0) > 0.001 {
|
|
return fmt.Errorf("artifact rarity percentages must sum to exactly 100 (currently %.4f)", total)
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
}
|