All checks were successful
release-tag / release-image (push) Successful in 3m57s
337 lines
13 KiB
Go
337 lines
13 KiB
Go
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 {
|
|
Kind string `json:"kind"`
|
|
SourceClientID string `json:"source_client_id"`
|
|
TaskID string `json:"task_id"`
|
|
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"`
|
|
}
|
|
|
|
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 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.Kind, &e.SourceClientID, &e.TaskID, &e.OldScore, &e.NewScore, &pointsMilli, &e.Multiplier, &e.Units, &e.Detail, &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
|
|
}
|