All checks were successful
release-tag / release-image (push) Successful in 3m57s
269 lines
9.1 KiB
Go
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
|
|
}
|
|
}
|
|
}
|
|
}
|