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
+15 -4
View File
@@ -40,11 +40,15 @@ type PlaceStats struct {
}
type PlaceEarning struct {
Kind string `json:"kind"`
SourceClientID string `json:"source_client_id"`
TaskID string `json:"task_id"`
OldScore float64 `json:"old_score"`
NewScore float64 `json:"new_score"`
OldScore float64 `json:"old_score,omitempty"`
NewScore float64 `json:"new_score,omitempty"`
Points float64 `json:"points"`
Multiplier float64 `json:"multiplier,omitempty"`
Units int64 `json:"units,omitempty"`
Detail string `json:"detail,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
@@ -204,14 +208,21 @@ func (s *Store) PlaceWallet(ctx context.Context, clientID string, pixelCostPoint
}
workers.Close()
rows, err := s.DB.QueryContext(ctx, `SELECT source_client_id,task_id,old_score,new_score,points_milli,created_at FROM place_progress_events WHERE owner_client_id=? ORDER BY created_at DESC,id DESC LIMIT 12`, clientID)
rows, err := s.DB.QueryContext(ctx, `
SELECT kind,source_client_id,task_id,old_score,new_score,points_milli,multiplier,units,detail,created_at FROM (
SELECT 'progress' AS kind,source_client_id,task_id,old_score,new_score,points_milli,1.0 AS multiplier,1 AS units,'' AS detail,created_at,id*2 AS sort_id
FROM place_progress_events WHERE owner_client_id=?
UNION ALL
SELECT kind,source_client_id,task_id,0.0 AS old_score,0.0 AS new_score,points_milli,multiplier,units,detail,created_at,id*2+1 AS sort_id
FROM place_bonus_events WHERE owner_client_id=?
) ORDER BY created_at DESC,sort_id DESC LIMIT 16`, clientID, clientID)
if err != nil {
return out, err
}
for rows.Next() {
var e PlaceEarning
var pointsMilli, createdMS int64
if err := rows.Scan(&e.SourceClientID, &e.TaskID, &e.OldScore, &e.NewScore, &pointsMilli, &createdMS); err != nil {
if err := rows.Scan(&e.Kind, &e.SourceClientID, &e.TaskID, &e.OldScore, &e.NewScore, &pointsMilli, &e.Multiplier, &e.Units, &e.Detail, &createdMS); err != nil {
rows.Close()
return out, err
}
+210
View File
@@ -0,0 +1,210 @@
package data
import (
"context"
"database/sql"
"errors"
"fmt"
"math"
"strings"
"time"
)
// PlaceBonusEvent is an immutable audit entry for non-score Place earnings.
// Progress earnings stay in place_progress_events so the exact score delta is
// retained, while draw/time/admin rewards are recorded here.
type PlaceBonusEvent struct {
Kind string `json:"kind"`
OwnerClientID string `json:"owner_client_id"`
SourceClientID string `json:"source_client_id"`
TaskID string `json:"task_id,omitempty"`
Points float64 `json:"points"`
Multiplier float64 `json:"multiplier"`
Units int64 `json:"units"`
Detail string `json:"detail,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
func resolvePlaceOwnerTx(ctx context.Context, tx *sql.Tx, sourceClientID string) (string, error) {
owner := sourceClientID
var delegatedOwner string
err := tx.QueryRowContext(ctx, `SELECT owner_client_id FROM identity_delegations WHERE worker_client_id=?`, sourceClientID).Scan(&delegatedOwner)
switch {
case err == nil && delegatedOwner != "":
owner = delegatedOwner
case errors.Is(err, sql.ErrNoRows):
case err != nil:
return "", err
}
return owner, nil
}
func creditPlaceBonusTx(ctx context.Context, tx *sql.Tx, eventKey, kind, owner, source, taskID string, pointsMilli int64, multiplier float64, units int64, detail string, nowMS int64) (bool, error) {
if pointsMilli <= 0 {
return false, nil
}
if strings.TrimSpace(eventKey) == "" || strings.TrimSpace(kind) == "" || strings.TrimSpace(owner) == "" || strings.TrimSpace(source) == "" {
return false, fmt.Errorf("invalid place bonus event")
}
if multiplier <= 0 {
multiplier = 1
}
if units < 1 {
units = 1
}
res, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO place_bonus_events(event_key,owner_client_id,source_client_id,task_id,kind,points_milli,multiplier,units,detail,created_at) VALUES(?,?,?,?,?,?,?,?,?,?)`,
eventKey, owner, source, taskID, kind, pointsMilli, multiplier, units, detail, nowMS)
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
if n != 1 {
return false, nil
}
if _, err := tx.ExecContext(ctx, `INSERT INTO place_wallets(client_id,balance_milli,earned_milli,updated_at) VALUES(?,?,?,?) ON CONFLICT(client_id) DO UPDATE SET balance_milli=place_wallets.balance_milli+excluded.balance_milli,earned_milli=place_wallets.earned_milli+excluded.earned_milli,updated_at=excluded.updated_at`, owner, pointsMilli, pointsMilli, nowMS); err != nil {
return false, err
}
return true, nil
}
// PlaceRewardDraw grants points when a lottery ticket is actually selected.
// eventKey is derived from task/source/sequence so retries cannot double-credit.
// beaconWeight is the same effective ticket weight used by the draw itself.
func (s *Store) PlaceRewardDraw(ctx context.Context, taskID, sourceClientID string, taskRevision, seq int64, basePoints int, beaconWeight int, useBeaconMultiplier bool) (string, float64, bool, error) {
if basePoints <= 0 {
return "", 0, false, nil
}
weight := 1
if useBeaconMultiplier && beaconWeight > 1 {
weight = beaconWeight
}
pointsMilli := int64(basePoints) * int64(weight) * 1000
nowMS := time.Now().UTC().UnixMilli()
tx, err := s.DB.BeginTx(ctx, nil)
if err != nil {
return "", 0, false, err
}
defer tx.Rollback()
owner, err := resolvePlaceOwnerTx(ctx, tx, sourceClientID)
if err != nil {
return "", 0, false, err
}
eventKey := fmt.Sprintf("draw|%s|%s|%d|%d", taskID, sourceClientID, taskRevision, seq)
detail := "lottery selected"
if weight > 1 {
detail = fmt.Sprintf("lottery selected · beacon x%d", weight)
}
created, err := creditPlaceBonusTx(ctx, tx, eventKey, "draw", owner, sourceClientID, taskID, pointsMilli, float64(weight), 1, detail, nowMS)
if err != nil {
return "", 0, false, err
}
if err := tx.Commit(); err != nil {
return "", 0, false, err
}
return owner, milliToPoints(pointsMilli), created, nil
}
// PlaceRewardTimeIntervals durably credits one or more active-time intervals.
// The hot-path clock itself is kept in server memory so losing guesses remain
// free of SQLite writes; only an actual payout reaches the database. The event
// key contains task revision and sequence so a retry cannot double-credit.
func (s *Store) PlaceRewardTimeIntervals(ctx context.Context, taskID, sourceClientID string, taskRevision, seq int64, pointsPerInterval int, intervals int64, intervalSec int) (string, float64, bool, error) {
if pointsPerInterval <= 0 || intervals <= 0 || intervalSec <= 0 {
return "", 0, false, nil
}
pointsMilli := intervals * int64(pointsPerInterval) * 1000
nowMS := time.Now().UTC().UnixMilli()
tx, err := s.DB.BeginTx(ctx, nil)
if err != nil {
return "", 0, false, err
}
defer tx.Rollback()
owner, err := resolvePlaceOwnerTx(ctx, tx, sourceClientID)
if err != nil {
return "", 0, false, err
}
eventKey := fmt.Sprintf("time|%s|%s|%d|%d", taskID, sourceClientID, taskRevision, seq)
detail := fmt.Sprintf("%d active interval(s) x %ds", intervals, intervalSec)
created, err := creditPlaceBonusTx(ctx, tx, eventKey, "time", owner, sourceClientID, taskID, pointsMilli, 1, intervals, detail, nowMS)
if err != nil {
return "", 0, false, err
}
if err := tx.Commit(); err != nil {
return "", 0, false, err
}
return owner, milliToPoints(pointsMilli), created, nil
}
// PlaceAdminGrant manually credits a player account. If a hosted worker ID is
// supplied, the reward is intentionally routed to its delegated owner so the
// same wallet semantics apply everywhere.
func (s *Store) PlaceAdminGrant(ctx context.Context, targetClientID string, points float64, reason string) (PlaceBonusEvent, error) {
if strings.TrimSpace(targetClientID) == "" {
return PlaceBonusEvent{}, fmt.Errorf("target client required")
}
if math.IsNaN(points) || math.IsInf(points, 0) || points < 0.001 || points > 1_000_000_000 {
return PlaceBonusEvent{}, fmt.Errorf("points must be 0.001..1000000000")
}
pointsMilli := int64(math.Round(points * 1000))
if pointsMilli < 1 {
return PlaceBonusEvent{}, fmt.Errorf("minimum grant is 0.001 points")
}
reason = strings.TrimSpace(reason)
if len(reason) > 500 {
return PlaceBonusEvent{}, fmt.Errorf("reason too long")
}
now := time.Now().UTC()
nowMS := now.UnixMilli()
tx, err := s.DB.BeginTx(ctx, nil)
if err != nil {
return PlaceBonusEvent{}, err
}
defer tx.Rollback()
var exists int
if err := tx.QueryRowContext(ctx, `SELECT count(*) FROM clients WHERE id=?`, targetClientID).Scan(&exists); err != nil {
return PlaceBonusEvent{}, err
}
if exists != 1 {
return PlaceBonusEvent{}, fmt.Errorf("client not found")
}
owner, err := resolvePlaceOwnerTx(ctx, tx, targetClientID)
if err != nil {
return PlaceBonusEvent{}, err
}
eventKey := NewID("admin_place_")
created, err := creditPlaceBonusTx(ctx, tx, eventKey, "admin", owner, targetClientID, "", pointsMilli, 1, 1, reason, nowMS)
if err != nil {
return PlaceBonusEvent{}, err
}
if !created {
return PlaceBonusEvent{}, fmt.Errorf("admin grant collision")
}
if err := tx.Commit(); err != nil {
return PlaceBonusEvent{}, err
}
return PlaceBonusEvent{Kind: "admin", OwnerClientID: owner, SourceClientID: targetClientID, Points: milliToPoints(pointsMilli), Multiplier: 1, Units: 1, Detail: reason, CreatedAt: now}, nil
}
func (s *Store) PlaceBonusEvents(ctx context.Context, clientID string, limit int) ([]PlaceBonusEvent, error) {
if limit < 1 || limit > 500 {
limit = 100
}
q := `SELECT kind,owner_client_id,source_client_id,task_id,points_milli,multiplier,units,detail,created_at FROM place_bonus_events WHERE (?='' OR owner_client_id=? OR source_client_id=?) ORDER BY created_at DESC,id DESC LIMIT ?`
rows, err := s.DB.QueryContext(ctx, q, clientID, clientID, clientID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]PlaceBonusEvent, 0, limit)
for rows.Next() {
var e PlaceBonusEvent
var pointsMilli, createdMS int64
if err := rows.Scan(&e.Kind, &e.OwnerClientID, &e.SourceClientID, &e.TaskID, &pointsMilli, &e.Multiplier, &e.Units, &e.Detail, &createdMS); err != nil {
return nil, err
}
e.Points = milliToPoints(pointsMilli)
e.CreatedAt = time.UnixMilli(createdMS).UTC()
out = append(out, e)
}
return out, rows.Err()
}
+92
View File
@@ -0,0 +1,92 @@
package data
import (
"context"
"testing"
"neuralhunt/internal/auth"
)
func TestPlaceDrawTimeAndAdminRewardsCreditDelegatedOwner(t *testing.T) {
ctx := context.Background()
db, err := OpenSQLite(ctx, t.TempDir()+"/place-rewards.db")
if err != nil {
t.Fatal(err)
}
defer db.Close()
s := New(db)
jwk := auth.PublicJWK{Kty: "EC", Crv: "P-256", X: "AQ", Y: "Ag"}
for _, id := range []string{"owner", "worker"} {
if err := s.UpsertClient(ctx, id, jwk); err != nil {
t.Fatal(err)
}
}
if err := s.SetIdentityDelegation(ctx, "worker", "owner"); err != nil {
t.Fatal(err)
}
if err := s.EnsureActiveTasks(ctx, 1, 28); err != nil {
t.Fatal(err)
}
tasks, err := s.ActiveTasks(ctx)
if err != nil || len(tasks) != 1 {
t.Fatalf("tasks=%d err=%v", len(tasks), err)
}
task := tasks[0]
owner, points, created, err := s.PlaceRewardDraw(ctx, task.ID, "worker", task.Revision, 7, 25, 3, true)
if err != nil {
t.Fatal(err)
}
if owner != "owner" || points != 75 || !created {
t.Fatalf("draw owner=%q points=%v created=%v", owner, points, created)
}
_, _, created, err = s.PlaceRewardDraw(ctx, task.ID, "worker", task.Revision, 7, 25, 3, true)
if err != nil {
t.Fatal(err)
}
if created {
t.Fatal("duplicate draw reward was credited")
}
owner, points, created, err = s.PlaceRewardTimeIntervals(ctx, task.ID, "worker", task.Revision, 8, 20, 1, 60)
if err != nil {
t.Fatal(err)
}
if owner != "owner" || points != 20 || !created {
t.Fatalf("time reward owner=%q points=%v created=%v", owner, points, created)
}
_, _, created, err = s.PlaceRewardTimeIntervals(ctx, task.ID, "worker", task.Revision, 8, 20, 1, 60)
if err != nil {
t.Fatal(err)
}
if created {
t.Fatal("duplicate time reward was credited")
}
grant, err := s.PlaceAdminGrant(ctx, "worker", 5.5, "support bonus")
if err != nil {
t.Fatal(err)
}
if grant.OwnerClientID != "owner" || grant.SourceClientID != "worker" || grant.Points != 5.5 || grant.Kind != "admin" {
t.Fatalf("grant=%+v", grant)
}
wallet, err := s.PlaceWallet(ctx, "owner", 100)
if err != nil {
t.Fatal(err)
}
if wallet.BalancePoints != 100.5 || wallet.EarnedPoints != 100.5 || wallet.AvailablePixels != 1 {
t.Fatalf("wallet=%+v", wallet)
}
if len(wallet.RecentEarnings) < 3 {
t.Fatalf("expected recent rewards, got %+v", wallet.RecentEarnings)
}
events, err := s.PlaceBonusEvents(ctx, "owner", 10)
if err != nil {
t.Fatal(err)
}
if len(events) != 3 {
t.Fatalf("bonus events=%+v", events)
}
}
+19
View File
@@ -265,3 +265,22 @@ CREATE TABLE IF NOT EXISTS place_progress_events (
);
CREATE INDEX IF NOT EXISTS place_progress_owner_idx ON place_progress_events(owner_client_id,created_at DESC);
CREATE INDEX IF NOT EXISTS place_progress_source_idx ON place_progress_events(source_client_id,created_at DESC);
-- V4.5 Place bonus economy: lottery draw rewards, active-time rewards and
-- manual admin grants. event_key makes externally triggered rewards idempotent.
CREATE TABLE IF NOT EXISTS place_bonus_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_key TEXT NOT NULL UNIQUE,
owner_client_id TEXT NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
source_client_id TEXT NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
task_id TEXT NOT NULL DEFAULT '',
kind TEXT NOT NULL,
points_milli INTEGER NOT NULL,
multiplier REAL NOT NULL DEFAULT 1,
units INTEGER NOT NULL DEFAULT 1,
detail TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS place_bonus_owner_idx ON place_bonus_events(owner_client_id,created_at DESC);
CREATE INDEX IF NOT EXISTS place_bonus_source_idx ON place_bonus_events(source_client_id,created_at DESC);
CREATE INDEX IF NOT EXISTS place_bonus_kind_idx ON place_bonus_events(kind,created_at DESC);