@@ -0,0 +1,325 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrPlaceInsufficientPoints = errors.New("not enough place points")
|
||||
ErrPlaceBounds = errors.New("pixel outside place canvas")
|
||||
ErrPlaceColor = errors.New("invalid place color")
|
||||
)
|
||||
|
||||
type PlacePixel struct {
|
||||
X int `json:"x"`
|
||||
Y int `json:"y"`
|
||||
ColorIndex int `json:"color_index"`
|
||||
ClientID string `json:"client_id"`
|
||||
PlacedAt time.Time `json:"placed_at"`
|
||||
Revision int64 `json:"revision"`
|
||||
}
|
||||
|
||||
type PlacePixelEvent struct {
|
||||
X int `json:"x"`
|
||||
Y int `json:"y"`
|
||||
ColorIndex int `json:"color_index"`
|
||||
ClientID string `json:"client_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Revision int64 `json:"revision"`
|
||||
}
|
||||
|
||||
type PlaceStats struct {
|
||||
Revision int64 `json:"revision"`
|
||||
FilledPixels int64 `json:"filled_pixels"`
|
||||
TotalPlacements int64 `json:"total_placements"`
|
||||
Participants int64 `json:"participants"`
|
||||
}
|
||||
|
||||
type PlaceEarning struct {
|
||||
SourceClientID string `json:"source_client_id"`
|
||||
TaskID string `json:"task_id"`
|
||||
OldScore float64 `json:"old_score"`
|
||||
NewScore float64 `json:"new_score"`
|
||||
Points float64 `json:"points"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type PlaceWallet struct {
|
||||
ClientID string `json:"client_id"`
|
||||
BalancePoints float64 `json:"balance_points"`
|
||||
EarnedPoints float64 `json:"earned_points"`
|
||||
SpentPoints float64 `json:"spent_points"`
|
||||
Placements int64 `json:"placements"`
|
||||
OwnedPixels int64 `json:"owned_pixels"`
|
||||
AvailablePixels int64 `json:"available_pixels"`
|
||||
LinkedWorkers []string `json:"linked_workers"`
|
||||
RecentEarnings []PlaceEarning `json:"recent_earnings"`
|
||||
}
|
||||
|
||||
func milliToPoints(v int64) float64 { return float64(v) / 1000.0 }
|
||||
|
||||
func (s *Store) PlaceRevision(ctx context.Context) (int64, error) {
|
||||
var revision int64
|
||||
err := s.DB.QueryRowContext(ctx, `SELECT revision FROM place_state WHERE id=1`).Scan(&revision)
|
||||
return revision, err
|
||||
}
|
||||
|
||||
func (s *Store) PlacePixelsOutside(ctx context.Context, width, height int) (int64, error) {
|
||||
var n int64
|
||||
err := s.DB.QueryRowContext(ctx, `SELECT count(*) FROM place_pixels WHERE x>=? OR y>=?`, width, height).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (s *Store) PlaceSnapshot(ctx context.Context) ([]PlacePixel, PlaceStats, error) {
|
||||
var stats PlaceStats
|
||||
if err := s.DB.QueryRowContext(ctx, `SELECT revision FROM place_state WHERE id=1`).Scan(&stats.Revision); err != nil {
|
||||
return nil, stats, err
|
||||
}
|
||||
if err := s.DB.QueryRowContext(ctx, `SELECT count(*) FROM place_pixels`).Scan(&stats.FilledPixels); err != nil {
|
||||
return nil, stats, err
|
||||
}
|
||||
if err := s.DB.QueryRowContext(ctx, `SELECT count(*) FROM place_pixel_events`).Scan(&stats.TotalPlacements); err != nil {
|
||||
return nil, stats, err
|
||||
}
|
||||
if err := s.DB.QueryRowContext(ctx, `SELECT count(*) FROM place_wallets WHERE earned_milli>0 OR placements>0`).Scan(&stats.Participants); err != nil {
|
||||
return nil, stats, err
|
||||
}
|
||||
|
||||
rows, err := s.DB.QueryContext(ctx, `SELECT x,y,color_index,client_id,placed_at,revision FROM place_pixels ORDER BY y,x`)
|
||||
if err != nil {
|
||||
return nil, stats, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]PlacePixel, 0, stats.FilledPixels)
|
||||
for rows.Next() {
|
||||
var p PlacePixel
|
||||
var placedMS int64
|
||||
if err := rows.Scan(&p.X, &p.Y, &p.ColorIndex, &p.ClientID, &placedMS, &p.Revision); err != nil {
|
||||
return nil, stats, err
|
||||
}
|
||||
p.PlacedAt = time.UnixMilli(placedMS).UTC()
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, stats, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) PlaceRecent(ctx context.Context, limit int) ([]PlacePixelEvent, error) {
|
||||
if limit < 1 || limit > 100 {
|
||||
limit = 30
|
||||
}
|
||||
rows, err := s.DB.QueryContext(ctx, `SELECT x,y,color_index,client_id,created_at,revision FROM place_pixel_events ORDER BY id DESC LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]PlacePixelEvent, 0, limit)
|
||||
for rows.Next() {
|
||||
var e PlacePixelEvent
|
||||
var createdMS int64
|
||||
if err := rows.Scan(&e.X, &e.Y, &e.ColorIndex, &e.ClientID, &createdMS, &e.Revision); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.CreatedAt = time.UnixMilli(createdMS).UTC()
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) PlaceChanges(ctx context.Context, after int64, limit int) ([]PlacePixel, int64, error) {
|
||||
if limit < 1 || limit > 5000 {
|
||||
limit = 2000
|
||||
}
|
||||
var revision int64
|
||||
if err := s.DB.QueryRowContext(ctx, `SELECT revision FROM place_state WHERE id=1`).Scan(&revision); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows, err := s.DB.QueryContext(ctx, `SELECT x,y,color_index,client_id,placed_at,revision FROM place_pixels WHERE revision>? ORDER BY revision LIMIT ?`, after, limit)
|
||||
if err != nil {
|
||||
return nil, revision, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]PlacePixel, 0)
|
||||
for rows.Next() {
|
||||
var p PlacePixel
|
||||
var placedMS int64
|
||||
if err := rows.Scan(&p.X, &p.Y, &p.ColorIndex, &p.ClientID, &placedMS, &p.Revision); err != nil {
|
||||
return nil, revision, err
|
||||
}
|
||||
p.PlacedAt = time.UnixMilli(placedMS).UTC()
|
||||
out = append(out, p)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, revision, err
|
||||
}
|
||||
if len(out) == limit && len(out) > 0 {
|
||||
revision = out[len(out)-1].Revision
|
||||
}
|
||||
return out, revision, nil
|
||||
}
|
||||
|
||||
func (s *Store) PlaceWallet(ctx context.Context, clientID string, pixelCostPoints int) (PlaceWallet, error) {
|
||||
now := time.Now().UTC().UnixMilli()
|
||||
if _, err := s.DB.ExecContext(ctx, `INSERT INTO place_wallets(client_id,updated_at) VALUES(?,?) ON CONFLICT(client_id) DO NOTHING`, clientID, now); err != nil {
|
||||
return PlaceWallet{}, err
|
||||
}
|
||||
var balance, earned, spent int64
|
||||
var out PlaceWallet
|
||||
out.ClientID = clientID
|
||||
out.LinkedWorkers = []string{}
|
||||
out.RecentEarnings = []PlaceEarning{}
|
||||
if err := s.DB.QueryRowContext(ctx, `SELECT balance_milli,earned_milli,spent_milli,placements FROM place_wallets WHERE client_id=?`, clientID).
|
||||
Scan(&balance, &earned, &spent, &out.Placements); err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.BalancePoints = milliToPoints(balance)
|
||||
out.EarnedPoints = milliToPoints(earned)
|
||||
out.SpentPoints = milliToPoints(spent)
|
||||
if err := s.DB.QueryRowContext(ctx, `SELECT count(*) FROM place_pixels WHERE client_id=?`, clientID).Scan(&out.OwnedPixels); err != nil {
|
||||
return out, err
|
||||
}
|
||||
costMilli := int64(pixelCostPoints) * 1000
|
||||
if costMilli > 0 {
|
||||
out.AvailablePixels = balance / costMilli
|
||||
}
|
||||
|
||||
workers, err := s.DB.QueryContext(ctx, `SELECT worker_client_id FROM identity_delegations WHERE owner_client_id=? ORDER BY worker_client_id`, clientID)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
for workers.Next() {
|
||||
var id string
|
||||
if err := workers.Scan(&id); err != nil {
|
||||
workers.Close()
|
||||
return out, err
|
||||
}
|
||||
out.LinkedWorkers = append(out.LinkedWorkers, id)
|
||||
}
|
||||
if err := workers.Err(); err != nil {
|
||||
workers.Close()
|
||||
return out, err
|
||||
}
|
||||
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)
|
||||
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 {
|
||||
rows.Close()
|
||||
return out, err
|
||||
}
|
||||
e.Points = milliToPoints(pointsMilli)
|
||||
e.CreatedAt = time.UnixMilli(createdMS).UTC()
|
||||
out.RecentEarnings = append(out.RecentEarnings, e)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return out, err
|
||||
}
|
||||
rows.Close()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) PlacePixelAt(ctx context.Context, x, y int) (PlacePixel, error) {
|
||||
var p PlacePixel
|
||||
var placedMS int64
|
||||
err := s.DB.QueryRowContext(ctx, `SELECT x,y,color_index,client_id,placed_at,revision FROM place_pixels WHERE x=? AND y=?`, x, y).
|
||||
Scan(&p.X, &p.Y, &p.ColorIndex, &p.ClientID, &placedMS, &p.Revision)
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
p.PlacedAt = time.UnixMilli(placedMS).UTC()
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// PlacePixel spends points and updates the shared canvas in one transaction.
|
||||
// There is intentionally no time cooldown: Hunt progress is the scarce resource.
|
||||
func (s *Store) PlacePixel(ctx context.Context, clientID string, x, y, colorIndex, width, height, pixelCostPoints, paletteSize int) (PlacePixel, PlaceWallet, error) {
|
||||
if x < 0 || y < 0 || x >= width || y >= height {
|
||||
return PlacePixel{}, PlaceWallet{}, ErrPlaceBounds
|
||||
}
|
||||
if colorIndex < 0 || colorIndex >= paletteSize {
|
||||
return PlacePixel{}, PlaceWallet{}, ErrPlaceColor
|
||||
}
|
||||
costMilli := int64(pixelCostPoints) * 1000
|
||||
if costMilli <= 0 {
|
||||
return PlacePixel{}, PlaceWallet{}, fmt.Errorf("invalid place pixel cost")
|
||||
}
|
||||
|
||||
tx, err := s.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return PlacePixel{}, PlaceWallet{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
nowMS := time.Now().UTC().UnixMilli()
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO place_wallets(client_id,updated_at) VALUES(?,?) ON CONFLICT(client_id) DO NOTHING`, clientID, nowMS); err != nil {
|
||||
return PlacePixel{}, PlaceWallet{}, err
|
||||
}
|
||||
res, err := tx.ExecContext(ctx, `UPDATE place_wallets SET balance_milli=balance_milli-?,spent_milli=spent_milli+?,placements=placements+1,updated_at=? WHERE client_id=? AND balance_milli>=?`, costMilli, costMilli, nowMS, clientID, costMilli)
|
||||
if err != nil {
|
||||
return PlacePixel{}, PlaceWallet{}, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n != 1 {
|
||||
return PlacePixel{}, PlaceWallet{}, ErrPlaceInsufficientPoints
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE place_state SET revision=revision+1,updated_at=? WHERE id=1`, nowMS); err != nil {
|
||||
return PlacePixel{}, PlaceWallet{}, err
|
||||
}
|
||||
var revision int64
|
||||
if err := tx.QueryRowContext(ctx, `SELECT revision FROM place_state WHERE id=1`).Scan(&revision); err != nil {
|
||||
return PlacePixel{}, PlaceWallet{}, err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO place_pixels(x,y,color_index,client_id,placed_at,revision) VALUES(?,?,?,?,?,?) ON CONFLICT(x,y) DO UPDATE SET color_index=excluded.color_index,client_id=excluded.client_id,placed_at=excluded.placed_at,revision=excluded.revision`, x, y, colorIndex, clientID, nowMS, revision); err != nil {
|
||||
return PlacePixel{}, PlaceWallet{}, err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO place_pixel_events(x,y,color_index,client_id,cost_milli,created_at,revision) VALUES(?,?,?,?,?,?,?)`, x, y, colorIndex, clientID, costMilli, nowMS, revision); err != nil {
|
||||
return PlacePixel{}, PlaceWallet{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return PlacePixel{}, PlaceWallet{}, err
|
||||
}
|
||||
|
||||
p := PlacePixel{X: x, Y: y, ColorIndex: colorIndex, ClientID: clientID, PlacedAt: time.UnixMilli(nowMS).UTC(), Revision: revision}
|
||||
wallet, err := s.PlaceWallet(ctx, clientID, pixelCostPoints)
|
||||
return p, wallet, err
|
||||
}
|
||||
|
||||
// placeRewardImprovementTx credits the reward identity for genuine durable
|
||||
// score progress. Delegated workers resolve to their owner at the instant the
|
||||
// progress is committed. A minimum of one milli-point ensures every positive
|
||||
// score increase is represented, even if it is extremely small.
|
||||
func (s *Store) placeRewardImprovementTx(ctx context.Context, tx *sql.Tx, taskID, sourceClientID string, oldScore, newScore float64, pointsPerScore int, nowMS int64) (string, int64, error) {
|
||||
if pointsPerScore <= 0 || newScore <= oldScore {
|
||||
return "", 0, nil
|
||||
}
|
||||
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):
|
||||
// Direct player progress credits the player identity itself.
|
||||
case err != nil:
|
||||
return "", 0, err
|
||||
}
|
||||
pointsMilli := int64((newScore-oldScore)*float64(pointsPerScore)*1000.0 + 0.5)
|
||||
if pointsMilli < 1 {
|
||||
pointsMilli = 1
|
||||
}
|
||||
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 "", 0, err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO place_progress_events(owner_client_id,source_client_id,task_id,old_score,new_score,points_milli,created_at) VALUES(?,?,?,?,?,?,?)`, owner, sourceClientID, taskID, oldScore, newScore, pointsMilli, nowMS); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return owner, pointsMilli, nil
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"neuralhunt/internal/auth"
|
||||
)
|
||||
|
||||
func TestPlaceWorkerProgressCreditsOwnerAndFundsPlacements(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, err := OpenSQLite(ctx, t.TempDir()+"/place.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"}
|
||||
if err := s.UpsertClient(ctx, "owner", jwk); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.UpsertClient(ctx, "worker", 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)
|
||||
}
|
||||
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner, milli, err := s.placeRewardImprovementTx(ctx, tx, tasks[0].ID, "worker", 10, 12.5, 100, time.Now().UTC().UnixMilli())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if owner != "owner" || milli != 250000 {
|
||||
t.Fatalf("owner=%q milli=%d", owner, milli)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
wallet, err := s.PlaceWallet(ctx, "owner", 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if wallet.BalancePoints != 250 || wallet.AvailablePixels != 2 || len(wallet.LinkedWorkers) != 1 || wallet.LinkedWorkers[0] != "worker" {
|
||||
t.Fatalf("unexpected wallet: %+v", wallet)
|
||||
}
|
||||
|
||||
first, wallet, err := s.PlacePixel(ctx, "owner", 7, 9, 3, 256, 256, 100, 32)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.Revision != 1 || wallet.BalancePoints != 150 || wallet.Placements != 1 || wallet.OwnedPixels != 1 {
|
||||
t.Fatalf("first=%+v wallet=%+v", first, wallet)
|
||||
}
|
||||
second, wallet, err := s.PlacePixel(ctx, "owner", 7, 9, 8, 256, 256, 100, 32)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second.Revision != 2 || wallet.BalancePoints != 50 || wallet.Placements != 2 || wallet.OwnedPixels != 1 {
|
||||
t.Fatalf("second=%+v wallet=%+v", second, wallet)
|
||||
}
|
||||
if _, _, err := s.PlacePixel(ctx, "owner", 8, 9, 1, 256, 256, 100, 32); !errors.Is(err, ErrPlaceInsufficientPoints) {
|
||||
t.Fatalf("expected insufficient points, got %v", err)
|
||||
}
|
||||
pixels, stats, err := s.PlaceSnapshot(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(pixels) != 1 || pixels[0].ColorIndex != 8 || stats.Revision != 2 || stats.TotalPlacements != 2 {
|
||||
t.Fatalf("pixels=%+v stats=%+v", pixels, stats)
|
||||
}
|
||||
changes, rev, err := s.PlaceChanges(ctx, 0, 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(changes) != 1 || changes[0].Revision != 2 || changes[0].ColorIndex != 8 || rev != 2 {
|
||||
t.Fatalf("changes=%+v rev=%d", changes, rev)
|
||||
}
|
||||
recent, err := s.PlaceRecent(ctx, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(recent) != 2 || recent[0].Revision != 2 || recent[1].Revision != 1 {
|
||||
t.Fatalf("recent=%+v", recent)
|
||||
}
|
||||
outside, err := s.PlacePixelsOutside(ctx, 7, 256)
|
||||
if err != nil || outside != 1 {
|
||||
t.Fatalf("outside=%d err=%v", outside, err)
|
||||
}
|
||||
outside, err = s.PlacePixelsOutside(ctx, 8, 10)
|
||||
if err != nil || outside != 0 {
|
||||
t.Fatalf("inside bounds outside=%d err=%v", outside, err)
|
||||
}
|
||||
}
|
||||
@@ -208,3 +208,60 @@ CREATE TABLE IF NOT EXISTS hosted_credit_events (
|
||||
last_error TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS hosted_credit_events_pending_idx ON hosted_credit_events(delivered_at,created_at);
|
||||
|
||||
-- Neural Place: a shared, r/place-inspired canvas funded exclusively by
|
||||
-- genuine Hunt score improvements. Point balances use milli-points so tiny
|
||||
-- score increases accumulate instead of being rounded away.
|
||||
CREATE TABLE IF NOT EXISTS place_wallets (
|
||||
client_id TEXT PRIMARY KEY REFERENCES clients(id) ON DELETE CASCADE,
|
||||
balance_milli INTEGER NOT NULL DEFAULT 0,
|
||||
earned_milli INTEGER NOT NULL DEFAULT 0,
|
||||
spent_milli INTEGER NOT NULL DEFAULT 0,
|
||||
placements INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS place_state (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
revision INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
INSERT OR IGNORE INTO place_state(id,revision,updated_at) VALUES(1,0,0);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS place_pixels (
|
||||
x INTEGER NOT NULL,
|
||||
y INTEGER NOT NULL,
|
||||
color_index INTEGER NOT NULL,
|
||||
client_id TEXT NOT NULL,
|
||||
placed_at INTEGER NOT NULL,
|
||||
revision INTEGER NOT NULL,
|
||||
PRIMARY KEY (x,y)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS place_pixels_revision_idx ON place_pixels(revision);
|
||||
CREATE INDEX IF NOT EXISTS place_pixels_client_idx ON place_pixels(client_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS place_pixel_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
x INTEGER NOT NULL,
|
||||
y INTEGER NOT NULL,
|
||||
color_index INTEGER NOT NULL,
|
||||
client_id TEXT NOT NULL,
|
||||
cost_milli INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
revision INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS place_pixel_events_created_idx ON place_pixel_events(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS place_pixel_events_client_idx ON place_pixel_events(client_id,created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS place_progress_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
owner_client_id TEXT NOT NULL,
|
||||
source_client_id TEXT NOT NULL,
|
||||
task_id TEXT NOT NULL,
|
||||
old_score REAL NOT NULL,
|
||||
new_score REAL NOT NULL,
|
||||
points_milli INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
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);
|
||||
|
||||
+35
-4
@@ -1689,13 +1689,15 @@ type ProfileCleanupCandidate struct {
|
||||
}
|
||||
|
||||
// InactiveNonWinnerClients returns identities whose persisted last activity is
|
||||
// older than cutoffMS and that have never been recorded as a task winner.
|
||||
// older than cutoffMS and that have no durable winner, delegation, or Neural
|
||||
// Place economy state that must survive generic profile cleanup.
|
||||
func (s *Store) InactiveNonWinnerClients(ctx context.Context, cutoffMS int64) ([]ProfileCleanupCandidate, error) {
|
||||
rows, err := s.DB.QueryContext(ctx, `SELECT c.id,c.last_seen
|
||||
FROM clients c
|
||||
WHERE c.last_seen < ?
|
||||
AND NOT EXISTS (SELECT 1 FROM tasks t WHERE t.winner_client_id=c.id OR t.winner_worker_client_id=c.id OR t.artifact_owner_client_id=c.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM identity_delegations d WHERE d.worker_client_id=c.id OR d.owner_client_id=c.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM place_wallets pw WHERE pw.client_id=c.id AND (pw.earned_milli>0 OR pw.spent_milli>0 OR pw.placements>0 OR pw.balance_milli>0))
|
||||
ORDER BY c.last_seen ASC`, cutoffMS)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1722,8 +1724,19 @@ func (s *Store) OldWinnerCount(ctx context.Context, cutoffMS int64) (int64, erro
|
||||
return n, err
|
||||
}
|
||||
|
||||
// OldPlaceParticipantCount reports old identities whose earned Place economy
|
||||
// state protects them from generic profile cleanup. Deleting such an identity
|
||||
// would otherwise cascade its wallet and destroy durable earned value.
|
||||
func (s *Store) OldPlaceParticipantCount(ctx context.Context, cutoffMS int64) (int64, error) {
|
||||
var n int64
|
||||
err := s.DB.QueryRowContext(ctx, `SELECT count(*) FROM clients c
|
||||
WHERE c.last_seen < ?
|
||||
AND EXISTS (SELECT 1 FROM place_wallets pw WHERE pw.client_id=c.id AND (pw.earned_milli>0 OR pw.spent_milli>0 OR pw.placements>0 OR pw.balance_milli>0))`, cutoffMS).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// DeleteInactiveNonWinnerClients deletes only the explicitly supplied client
|
||||
// IDs and re-checks both age and winner protection inside the transaction. The
|
||||
// IDs and re-checks age plus winner/delegation/Place protection in the transaction. The
|
||||
// re-check makes a concurrent login safe because login/WS connect refreshes
|
||||
// clients.last_seen before this DELETE can match it.
|
||||
func (s *Store) DeleteInactiveNonWinnerClients(ctx context.Context, cutoffMS int64, ids []string) ([]string, error) {
|
||||
@@ -1746,7 +1759,8 @@ func (s *Store) DeleteInactiveNonWinnerClients(ctx context.Context, cutoffMS int
|
||||
res, err := tx.ExecContext(ctx, `DELETE FROM clients
|
||||
WHERE id=? AND last_seen < ?
|
||||
AND NOT EXISTS (SELECT 1 FROM tasks t WHERE t.winner_client_id=clients.id OR t.winner_worker_client_id=clients.id OR t.artifact_owner_client_id=clients.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM identity_delegations d WHERE d.worker_client_id=clients.id OR d.owner_client_id=clients.id)`, id, cutoffMS)
|
||||
AND NOT EXISTS (SELECT 1 FROM identity_delegations d WHERE d.worker_client_id=clients.id OR d.owner_client_id=clients.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM place_wallets pw WHERE pw.client_id=clients.id AND (pw.earned_milli>0 OR pw.spent_milli>0 OR pw.placements>0 OR pw.balance_milli>0))`, id, cutoffMS)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1771,6 +1785,14 @@ func (s *Store) TouchClient(ctx context.Context, id string) {
|
||||
_, _ = s.DB.ExecContext(ctx, `UPDATE clients SET last_seen=? WHERE id=?`, time.Now().UTC().UnixMilli(), id)
|
||||
}
|
||||
|
||||
// TouchClientThrottled marks passive authenticated activity without turning a
|
||||
// frequently-polled read endpoint into a SQLite write on every request.
|
||||
func (s *Store) TouchClientThrottled(ctx context.Context, id string, minInterval time.Duration) {
|
||||
now := time.Now().UTC().UnixMilli()
|
||||
cutoff := now - minInterval.Milliseconds()
|
||||
_, _ = s.DB.ExecContext(ctx, `UPDATE clients SET last_seen=? WHERE id=? AND last_seen<?`, now, id, cutoff)
|
||||
}
|
||||
|
||||
// Presence lease helpers replace Redis SET NX + TTL.
|
||||
func (s *Store) AcquirePresence(ctx context.Context, cid, sessionID string, ttl time.Duration) error {
|
||||
tx, err := s.DB.BeginTx(ctx, nil)
|
||||
@@ -1861,7 +1883,7 @@ func (s *Store) LoadGuessState(ctx context.Context, taskID, cid string) (GuessSt
|
||||
// sequence/count values include all losing guesses that happened in memory
|
||||
// since the previous checkpoint, so a restart resumes from the latest durable
|
||||
// improvement rather than writing every false guess.
|
||||
func (s *Store) PersistImprovement(ctx context.Context, t SecretTask, cid, rewardOwner string, nextSeq, guessCount int64, lastGuess time.Time, score float64, guess, sig string, correct bool, beaconPath, beaconBoostedPath string, beaconRound uint64) (Point, error) {
|
||||
func (s *Store) PersistImprovement(ctx context.Context, t SecretTask, cid, rewardOwner string, nextSeq, guessCount int64, lastGuess time.Time, score float64, guess, sig string, correct bool, beaconPath, beaconBoostedPath string, beaconRound uint64, placePointsPerScore int) (Point, error) {
|
||||
tx, err := s.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return Point{}, err
|
||||
@@ -1878,6 +1900,10 @@ func (s *Store) PersistImprovement(ctx context.Context, t SecretTask, cid, rewar
|
||||
if paused != 0 {
|
||||
return Point{}, ErrTaskPaused
|
||||
}
|
||||
var oldScore float64
|
||||
if err := tx.QueryRowContext(ctx, `SELECT score FROM task_points WHERE task_id=? AND client_id=?`, t.ID, cid).Scan(&oldScore); err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return Point{}, err
|
||||
}
|
||||
x, y, z := core.Position(cid, score)
|
||||
lastMS := lastGuess.UTC().UnixMilli()
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO task_points(task_id,client_id,score,x,y,z,guess_count,next_seq,last_guess_at)
|
||||
@@ -1887,6 +1913,11 @@ func (s *Store) PersistImprovement(ctx context.Context, t SecretTask, cid, rewar
|
||||
if err != nil {
|
||||
return Point{}, err
|
||||
}
|
||||
if score > oldScore {
|
||||
if _, _, err := s.placeRewardImprovementTx(ctx, tx, t.ID, cid, oldScore, score, placePointsPerScore, lastMS); err != nil {
|
||||
return Point{}, err
|
||||
}
|
||||
}
|
||||
if correct {
|
||||
if rewardOwner == "" {
|
||||
rewardOwner = cid
|
||||
|
||||
Reference in New Issue
Block a user