211 lines
8.1 KiB
Go
211 lines
8.1 KiB
Go
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()
|
|
}
|