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