Files
jbergner 1a56dec079
All checks were successful
release-tag / release-image (push) Successful in 3m57s
RC-16
2026-08-14 14:19:16 +02:00

269 lines
9.1 KiB
Go

package server
import (
"context"
"errors"
"net/http"
"strconv"
"time"
"neuralhunt/internal/data"
wsx "neuralhunt/internal/ws"
)
type placeTimeClock struct {
LastSeq int64
LastAt time.Time
Active time.Duration
}
// rewardPlaceActiveTime keeps the high-frequency activity clock in memory so
// ordinary losing guesses remain free of SQLite writes. Only a completed time
// interval creates a durable wallet/audit transaction. A restart can therefore
// discard at most one partial interval, never already awarded points.
func (s *Server) rewardPlaceActiveTime(ctx context.Context, taskID, sourceClientID string, taskRevision, seq int64, pointsPerInterval, intervalSec, maxGapSec int) error {
if pointsPerInterval <= 0 || intervalSec <= 0 || maxGapSec <= 0 {
return nil
}
now := time.Now().UTC()
interval := time.Duration(intervalSec) * time.Second
maxGap := time.Duration(maxGapSec) * time.Second
key := taskID + "\x00" + sourceClientID + "\x00" + strconv.FormatInt(taskRevision, 10)
s.placeTimeMu.Lock()
if s.placeTimeClocks == nil {
s.placeTimeClocks = make(map[string]placeTimeClock)
}
if s.placeTimeSweep.IsZero() || now.Sub(s.placeTimeSweep) >= 10*time.Minute {
cutoff := now.Add(-24 * time.Hour)
for k, clock := range s.placeTimeClocks {
if clock.LastAt.Before(cutoff) {
delete(s.placeTimeClocks, k)
}
}
s.placeTimeSweep = now
}
clock, ok := s.placeTimeClocks[key]
if !ok {
s.placeTimeClocks[key] = placeTimeClock{LastSeq: seq, LastAt: now}
s.placeTimeMu.Unlock()
return nil
}
if seq <= clock.LastSeq {
s.placeTimeMu.Unlock()
return nil
}
gap := now.Sub(clock.LastAt)
if gap > 0 && gap <= maxGap {
clock.Active += gap
}
intervals := int64(clock.Active / interval)
if intervals > 0 {
clock.Active -= time.Duration(intervals) * interval
}
clock.LastSeq = seq
clock.LastAt = now
s.placeTimeClocks[key] = clock
s.placeTimeMu.Unlock()
if intervals <= 0 {
return nil
}
_, _, created, err := s.store.PlaceRewardTimeIntervals(ctx, taskID, sourceClientID, taskRevision, seq, pointsPerInterval, intervals, intervalSec)
if err != nil {
return err
}
if created {
s.runtime.MarkSQLiteWrite()
}
return nil
}
var placePalette = []string{
"#FFFFFF", "#D4D7D9", "#898D90", "#515252", "#000000", "#FFB470", "#FF8A3D", "#E64B35",
"#FF5C7A", "#E33765", "#B72B73", "#811E9F", "#B44AC0", "#7E57C2", "#5A4FCF", "#3690EA",
"#2450A4", "#51E9F4", "#00A8B8", "#00CCC0", "#34D399", "#00A368", "#00756F", "#7EED56",
"#46A758", "#A8D26D", "#FFD635", "#F9A31B", "#D6A25E", "#9C6926", "#6D482F", "#493B2A",
}
func (s *Server) placeConfig() map[string]any {
cfg := s.settings.Get()
return map[string]any{
"enabled": cfg.PlaceEnabled == 1,
"width": cfg.PlaceWidth,
"height": cfg.PlaceHeight,
"points_per_score": cfg.PlacePointsPerScore,
"draw_points": cfg.PlaceDrawPoints,
"draw_beacon_multiplier": cfg.PlaceDrawBeaconMultiplier == 1,
"time_points": cfg.PlaceTimePoints,
"time_interval_sec": cfg.PlaceTimeIntervalSec,
"time_max_gap_sec": cfg.PlaceTimeMaxGapSec,
"pixel_cost": cfg.PlacePixelCost,
"palette": placePalette,
"economy_explanation": "Place-Punkte entstehen durch Score-Fortschritt, gezogene Lotterie-Tipps und aktive Hunt-Zeit. Beacon-Boosts können Draw-Punkte mit demselben Pfadgewicht multiplizieren.",
"worker_progress_credits": true,
"worker_bonus_credits_to_owner": true,
}
}
func (s *Server) publicPlace(w http.ResponseWriter, r *http.Request) {
pixels, stats, err := s.store.PlaceSnapshot(r.Context())
if err != nil {
jsonOut(w, 500, map[string]string{"error": "place snapshot failed"})
return
}
recent, _ := s.store.PlaceRecent(r.Context(), 30)
w.Header().Set("Cache-Control", "no-store")
jsonOut(w, 200, map[string]any{"config": s.placeConfig(), "stats": stats, "pixels": pixels, "recent": recent})
}
func (s *Server) publicPlaceChanges(w http.ResponseWriter, r *http.Request) {
after, _ := strconv.ParseInt(r.URL.Query().Get("after"), 10, 64)
pixels, revision, err := s.store.PlaceChanges(r.Context(), after, 5000)
if err != nil {
jsonOut(w, 500, map[string]string{"error": "place changes failed"})
return
}
w.Header().Set("Cache-Control", "no-store")
jsonOut(w, 200, map[string]any{"revision": revision, "pixels": pixels})
}
func (s *Server) placeMe(w http.ResponseWriter, r *http.Request) {
c := claims(r)
if !s.store.ClientExists(r.Context(), c.ClientID) {
jsonOut(w, http.StatusUnauthorized, map[string]string{"error": "identity not registered in this database"})
return
}
s.store.TouchClientThrottled(r.Context(), c.ClientID, 5*time.Minute)
cfg := s.settings.Get()
wallet, err := s.store.PlaceWallet(r.Context(), c.ClientID, cfg.PlacePixelCost)
if err != nil {
jsonOut(w, 500, map[string]string{"error": "place wallet failed"})
return
}
jsonOut(w, 200, map[string]any{"config": s.placeConfig(), "wallet": wallet})
}
func (s *Server) placePixel(w http.ResponseWriter, r *http.Request) {
cfg := s.settings.Get()
if cfg.PlaceEnabled != 1 {
jsonAPIError(w, http.StatusLocked, "place_disabled", "Neural Place is currently disabled", nil)
return
}
var in struct {
X int `json:"x"`
Y int `json:"y"`
ColorIndex int `json:"color_index"`
}
if err := decode(r, &in); err != nil {
jsonOut(w, 400, map[string]string{"error": "bad json: " + err.Error()})
return
}
c := claims(r)
if !s.store.ClientExists(r.Context(), c.ClientID) {
jsonOut(w, http.StatusUnauthorized, map[string]string{"error": "identity not registered in this database"})
return
}
s.store.TouchClient(r.Context(), c.ClientID)
p, wallet, err := s.store.PlacePixel(r.Context(), c.ClientID, in.X, in.Y, in.ColorIndex, cfg.PlaceWidth, cfg.PlaceHeight, cfg.PlacePixelCost, len(placePalette))
if err != nil {
// The pixel transaction may already be committed even if the optional
// wallet refresh immediately afterwards fails. Never report that case as
// a failed placement: a client retry would spend the pixel cost twice.
if p.Revision > 0 {
_ = s.placeHub.Publish(r.Context(), wsx.Event{Type: "place_pixel", Data: p})
jsonOut(w, 200, map[string]any{"pixel": p, "wallet": nil, "wallet_refresh_required": true})
return
}
switch {
case errors.Is(err, data.ErrPlaceInsufficientPoints):
jsonAPIError(w, http.StatusPaymentRequired, "insufficient_place_points", "not enough Place points for this pixel", map[string]any{"pixel_cost": cfg.PlacePixelCost})
case errors.Is(err, data.ErrPlaceBounds), errors.Is(err, data.ErrPlaceColor):
jsonOut(w, 400, map[string]string{"error": err.Error()})
default:
jsonOut(w, 500, map[string]string{"error": "pixel placement failed"})
}
return
}
_ = s.placeHub.Publish(r.Context(), wsx.Event{Type: "place_pixel", Data: p})
jsonOut(w, 200, map[string]any{"pixel": p, "wallet": wallet})
}
func (s *Server) adminPlaceGrant(w http.ResponseWriter, r *http.Request) {
var in struct {
TargetClientID string `json:"target_client_id"`
Points float64 `json:"points"`
Reason string `json:"reason"`
}
if err := decode(r, &in); err != nil {
jsonOut(w, 400, map[string]string{"error": "bad json: " + err.Error()})
return
}
event, err := s.store.PlaceAdminGrant(r.Context(), in.TargetClientID, in.Points, in.Reason)
if err != nil {
jsonOut(w, 400, map[string]string{"error": err.Error()})
return
}
wallet, walletErr := s.store.PlaceWallet(r.Context(), event.OwnerClientID, s.settings.Get().PlacePixelCost)
if walletErr != nil {
jsonOut(w, 200, map[string]any{"event": event, "wallet": nil, "wallet_refresh_required": true})
return
}
jsonOut(w, 200, map[string]any{"event": event, "wallet": wallet})
}
func (s *Server) adminPlaceEarnings(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
clientID := r.URL.Query().Get("client_id")
events, err := s.store.PlaceBonusEvents(r.Context(), clientID, limit)
if err != nil {
jsonOut(w, 500, map[string]string{"error": err.Error()})
return
}
jsonOut(w, 200, events)
}
func (s *Server) placeWS(w http.ResponseWriter, r *http.Request) {
if !acquireWSCap(&s.placeWSCount, s.maxPlaceWS) {
http.Error(w, "place websocket capacity reached", http.StatusServiceUnavailable)
return
}
defer s.placeWSCount.Add(-1)
conn, err := s.upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
cl := wsx.NewClient(conn, "", "place", true)
s.placeHub.Add(cl)
defer s.placeHub.Remove(cl)
revision, _ := s.store.PlaceRevision(r.Context())
cl.Enqueue(wsx.Event{Type: "place_ready", Data: map[string]any{"revision": revision}})
conn.SetReadLimit(1024)
_ = conn.SetReadDeadline(time.Now().Add(90 * time.Second))
conn.SetPongHandler(func(string) error { return conn.SetReadDeadline(time.Now().Add(90 * time.Second)) })
ping := time.NewTicker(30 * time.Second)
defer ping.Stop()
done := make(chan struct{})
go func() {
defer close(done)
for {
if _, _, err := conn.ReadMessage(); err != nil {
return
}
}
}()
for {
select {
case <-done:
return
case <-r.Context().Done():
return
case <-ping.C:
if err := cl.Ping(); err != nil {
return
}
}
}
}