Files
jbergner 1a56dec079
release-tag / release-image (push) Successful in 3m57s
RC-16
2026-08-14 14:19:16 +02:00

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()
}