+117
-8
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user