Files
neural-hunt/internal/data/place.go
jbergner 1a56dec079
All checks were successful
release-tag / release-image (push) Successful in 3m57s
RC-16
2026-08-14 14:19:16 +02:00

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
}