PortalActivityEntity.java

package cl.prgm.portaltrip.infrastructure.persistence;

import java.math.BigDecimal;
import java.time.Duration;
import java.time.OffsetDateTime;
import java.util.UUID;
import cl.prgm.portaltrip.domain.exception.DomainValidationException;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

/** One bounded activity record per user. The service holds the user's database lock. */
@Entity
@Table(name = "portal_activity")
public class PortalActivityEntity {
	@Id
	@Column(name = "user_id")
	private UUID userId;
	@Column(name = "cycle_id", nullable = false)
	private UUID cycleId;
	@Column(name = "started_at", nullable = false)
	private OffsetDateTime startedAt;
	@Column(name = "sampled_at", nullable = false)
	private OffsetDateTime sampledAt;
	@Column(nullable = false)
	private int sequence;
	@Column(name = "active_ms", nullable = false)
	private long activeMs;
	@Column(nullable = false)
	private double distance;
	@Column(nullable = false, precision = 12, scale = 2)
	private BigDecimal payout;

	protected PortalActivityEntity() {}

	public static PortalActivityEntity start(UUID userId, OffsetDateTime now) {
		PortalActivityEntity activity = new PortalActivityEntity();
		activity.userId = userId;
		activity.restart(now);
		return activity;
	}

	public void restart(OffsetDateTime now) {
		cycleId = UUID.randomUUID();
		startedAt = sampledAt = now;
		sequence = 0;
		activeMs = 0;
		distance = 0;
		payout = BigDecimal.ZERO;
	}

	public boolean expired(OffsetDateTime now) {
		return Duration.between(startedAt, now).toSeconds() >= 90;
	}

	public boolean completed() { return payout.signum() > 0; }
	public UUID cycleId() { return cycleId; }
	public int nextSequence() { return sequence + 1; }
	public BigDecimal payout() { return payout; }
	public double movement() { return distance; }
	public long activeDurationMs() { return activeMs; }
	public double progress() { return Math.min(1, Math.min(activeMs / 2400.0, distance / 1.5)); }
	public void complete(BigDecimal amount) { payout = amount; }

	public void sample(UUID cycle, int next, long active, double movement, OffsetDateTime now) {
		if (!cycleId.equals(cycle)) throw invalid("Unknown portal cycle");
		// A lost response can be retried, including after the reward was committed.
		if (next == sequence) return;
		if (completed() || expired(now)) throw invalid("Portal cycle ended; start again");
		if (next != sequence + 1) throw invalid("Portal samples must be sent in order");
		long elapsed = Duration.between(sampledAt, now).toMillis();
		if (elapsed < 750) throw invalid("Portal samples are too frequent");
		if (active < 0 || active > 5000 || !Double.isFinite(movement) || movement < 0 || movement > 20)
			throw invalid("Invalid portal activity");
		if (active > elapsed + 150) throw invalid("Activity exceeds server elapsed time");
		// Time while stationary does not qualify. Long gaps require fresh sustained activity.
		if (elapsed > 5000 || active == 0 || movement == 0) {
			activeMs = 0;
			distance = 0;
		} else {
			activeMs += Math.min(active, elapsed);
			distance += Math.min(movement, Math.min(active, elapsed) / 1000.0 * 4);
		}
		sampledAt = now;
		sequence = next;
	}

	private static DomainValidationException invalid(String message) {
		return new DomainValidationException(message);
	}
}