RC-16
release-tag / release-image (push) Successful in 3m57s

This commit is contained in:
2026-08-14 14:19:16 +02:00
parent 52d33b25fd
commit 1a56dec079
16 changed files with 765 additions and 33 deletions
+117 -8
View File
@@ -1,6 +1,7 @@
package server
import (
"context"
"errors"
"net/http"
"strconv"
@@ -10,6 +11,74 @@ import (
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",
@@ -20,14 +89,20 @@ var placePalette = []string{
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,
"pixel_cost": cfg.PlacePixelCost,
"palette": placePalette,
"economy_explanation": "1.00 Hunt-Score-Fortschritt erzeugt points_per_score Punkte. Ein Placement kostet pixel_cost Punkte.",
"worker_progress_credits": true,
"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,
}
}
@@ -114,6 +189,40 @@ func (s *Server) placePixel(w http.ResponseWriter, r *http.Request) {
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)
+43 -10
View File
@@ -55,17 +55,21 @@ type Server struct {
maxUserWS, maxLeaderboardWS, maxPlaceWS int64
userWSCount, leaderboardWSCount, placeWSCount, adminWSCount atomic.Int64
adminSessions sync.Map // sid -> exp unix seconds
placeTimeMu sync.Mutex
placeTimeClocks map[string]placeTimeClock
placeTimeSweep time.Time
}
func New(store *data.Store, a *auth.Manager, sm *settings.Manager, hub *wsx.Hub, runtimeState *rtx.State, artifactDir string, artifactWorker *artifact.Worker) *Server {
s := &Server{
store: store,
auth: a,
settings: sm,
hub: hub,
placeHub: wsx.New(),
runtime: runtimeState,
artifactWorker: artifactWorker,
store: store,
auth: a,
settings: sm,
hub: hub,
placeHub: wsx.New(),
runtime: runtimeState,
placeTimeClocks: make(map[string]placeTimeClock),
artifactWorker: artifactWorker,
lottery: newGuessLottery(func(d beaconDrawAudit) {
_ = store.RecordBeaconDraw(context.Background(), d.TaskID, d.WindowEnd, d.BeaconID, d.BeaconRound, d.Randomness, d.Signature, d.BoostedPath, d.Tickets, d.Selected)
}),
@@ -348,6 +352,8 @@ func (s *Server) Routes() http.Handler {
r.Put("/api/admin/settings", s.adminSettingsPut)
r.Get("/api/admin/tasks", s.adminTasks)
r.Get("/api/admin/clients", s.adminClients)
r.Get("/api/admin/place/earnings", s.adminPlaceEarnings)
r.Post("/api/admin/place/points", s.adminPlaceGrant)
r.Get("/api/admin/tasks/{id}/points", s.adminPoints)
r.Get("/api/admin/tasks/{id}/actions", s.adminTaskActions)
r.Put("/api/admin/tasks/{id}/config", s.adminTaskConfigPut)
@@ -761,6 +767,15 @@ func (s *Server) guess(w http.ResponseWriter, r *http.Request) {
return
}
// Active-time Place rewards are driven only by valid, signed Hunt activity
// with an active presence. Duplicate sequence numbers are ignored by the
// in-memory activity clock, and long offline gaps are never backfilled.
if cfg.PlaceEnabled == 1 && cfg.PlaceTimePoints > 0 {
if err := s.rewardPlaceActiveTime(r.Context(), t.ID, c.ClientID, t.Revision, in.Seq, cfg.PlaceTimePoints, cfg.PlaceTimeIntervalSec, cfg.PlaceTimeMaxGapSec); err != nil {
log.Printf("place active-time reward %s/%s seq=%d: %v", t.ID, c.ClientID, in.Seq, err)
}
}
var draw lotteryResult
if cfg.GuessLotteryMaxAccepted > 0 {
drawResult, drawErr := s.lottery.enter(r.Context(), t.ID, c.ClientID, in.Seq, in.BeaconPath, time.Duration(cfg.GuessLotteryWindowSec)*time.Second, cfg.GuessLotteryMaxAccepted, cfg.BeaconHuntEnabled == 1, cfg.BeaconBonusWeight)
@@ -797,6 +812,20 @@ func (s *Server) guess(w http.ResponseWriter, r *http.Request) {
return
}
t = fresh
if draw.Selected {
rewardCfg := s.settings.Get()
if rewardCfg.PlaceEnabled == 1 && rewardCfg.PlaceDrawPoints > 0 {
weight := draw.Weight
if weight < 1 {
weight = 1
}
if _, _, created, err := s.store.PlaceRewardDraw(r.Context(), t.ID, c.ClientID, t.Revision, in.Seq, rewardCfg.PlaceDrawPoints, weight, rewardCfg.PlaceDrawBeaconMultiplier == 1); err != nil {
log.Printf("place draw reward %s/%s seq=%d: %v", t.ID, c.ClientID, in.Seq, err)
} else if created {
s.runtime.MarkSQLiteWrite()
}
}
}
if !draw.Selected {
next, skipErr := s.runtime.SkipLottery(t.Task, c.ClientID, in.Seq, minInterval)
if skipErr != nil {
@@ -1544,7 +1573,9 @@ func (s *Server) adminClients(w http.ResponseWriter, r *http.Request) {
}
rows, err := s.store.DB.QueryContext(r.Context(), `SELECT c.id,c.last_seen,
(SELECT count(*) FROM tasks t WHERE t.winner_client_id=c.id AND t.status='completed') AS wins,
(SELECT count(*) FROM tasks t WHERE COALESCE(t.artifact_owner_client_id,t.winner_client_id)=c.id AND t.status='completed' AND t.artifact_status='ready') AS nft_count
(SELECT count(*) FROM tasks t WHERE COALESCE(t.artifact_owner_client_id,t.winner_client_id)=c.id AND t.status='completed' AND t.artifact_status='ready') AS nft_count,
COALESCE((SELECT d.owner_client_id FROM identity_delegations d WHERE d.worker_client_id=c.id),c.id) AS place_owner_client_id,
COALESCE((SELECT pw.balance_milli FROM place_wallets pw WHERE pw.client_id=COALESCE((SELECT d2.owner_client_id FROM identity_delegations d2 WHERE d2.worker_client_id=c.id),c.id)),0) AS place_balance_milli
FROM clients c WHERE (?='' OR lower(c.id) LIKE '%'||lower(?)||'%') ORDER BY c.last_seen DESC LIMIT ?`, q, q, limit)
if err != nil {
jsonOut(w, 500, map[string]string{"error": err.Error()})
@@ -1556,11 +1587,13 @@ func (s *Server) adminClients(w http.ResponseWriter, r *http.Request) {
var id string
var lastSeen int64
var wins, nfts int
if err := rows.Scan(&id, &lastSeen, &wins, &nfts); err != nil {
var placeOwner string
var placeBalanceMilli int64
if err := rows.Scan(&id, &lastSeen, &wins, &nfts, &placeOwner, &placeBalanceMilli); err != nil {
jsonOut(w, 500, map[string]string{"error": err.Error()})
return
}
out = append(out, map[string]any{"client_id": id, "last_seen": lastSeen, "wins": wins, "nft_count": nfts})
out = append(out, map[string]any{"client_id": id, "last_seen": lastSeen, "wins": wins, "nft_count": nfts, "place_owner_client_id": placeOwner, "place_balance_points": float64(placeBalanceMilli) / 1000.0})
}
jsonOut(w, 200, out)
}