+15
-4
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user