feat(matchmaker): add map fairness monitoring and auto-retirement (§14.6)
Implements the full map lifecycle audit as a hourly ticker in the matchmaker: 1. updateMapFairnessStats: recompute per-slot win counts from completed matches into the map_fairness table 2. flagUnfairMaps: flag maps where any slot deviates >10pp from expected (1/N) across 80+ matches → status='probation' 3. retireDislikedMaps: force-retire maps with >20 net negative votes 4. pruneLowEngagementMaps: monthly bottom-10% engagement prune per tier 5. promoteClassicMaps: top-5 all-time engagement, 3+ months → 'classic' Matchmaker already filters retired maps and gives probation maps 50% reduced selection probability in selectMapLRU. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
6c1f031071
commit
3b94b7eccb
2 changed files with 554 additions and 0 deletions
277
cmd/acb-matchmaker/map_fairness.go
Normal file
277
cmd/acb-matchmaker/map_fairness.go
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
fairnessMinGames = 80
|
||||
fairnessThresholdPP = 0.10
|
||||
voteForceRetireThreshold = -20
|
||||
engagementPrunePct = 0.10
|
||||
classicMinMonths = 3
|
||||
classicTopN = 5
|
||||
)
|
||||
|
||||
// tickFairnessAudit runs the full map lifecycle audit:
|
||||
// 1. Update map_fairness from completed matches
|
||||
// 2. Flag positionally unfair maps as probation
|
||||
// 3. Force-retire maps with >20 net negative votes
|
||||
// 4. Monthly: prune bottom 10% by engagement
|
||||
// 5. Promote top-5 sustained maps to classic
|
||||
func (m *Matchmaker) tickFairnessAudit(ctx context.Context) {
|
||||
if err := m.updateMapFairnessStats(ctx); err != nil {
|
||||
log.Printf("fairness-audit: update stats error: %v", err)
|
||||
}
|
||||
if err := m.flagUnfairMaps(ctx); err != nil {
|
||||
log.Printf("fairness-audit: flag unfair error: %v", err)
|
||||
}
|
||||
if err := m.retireDislikedMaps(ctx); err != nil {
|
||||
log.Printf("fairness-audit: retire disliked error: %v", err)
|
||||
}
|
||||
if err := m.pruneLowEngagementMaps(ctx); err != nil {
|
||||
log.Printf("fairness-audit: prune engagement error: %v", err)
|
||||
}
|
||||
if err := m.promoteClassicMaps(ctx); err != nil {
|
||||
log.Printf("fairness-audit: promote classic error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// updateMapFairnessStats recomputes per-slot win counts from match_participants
|
||||
// and writes them into the map_fairness table for all active/probation maps.
|
||||
func (m *Matchmaker) updateMapFairnessStats(ctx context.Context) error {
|
||||
// For each map+slot, count completed matches where that slot won.
|
||||
rows, err := m.db.QueryContext(ctx, `
|
||||
SELECT m.map_id, mp.player_slot,
|
||||
COUNT(*) AS games,
|
||||
COUNT(*) FILTER (WHERE m.winner = mp.player_slot) AS wins
|
||||
FROM match_participants mp
|
||||
JOIN matches m ON m.match_id = mp.match_id
|
||||
JOIN maps map ON map.map_id = m.map_id
|
||||
WHERE m.status = 'completed'
|
||||
AND map.status IN ('active', 'probation')
|
||||
GROUP BY m.map_id, mp.player_slot
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("query fairness stats: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type fairnessRow struct {
|
||||
MapID string
|
||||
PlayerSlot int
|
||||
Games int
|
||||
Wins int
|
||||
}
|
||||
var stats []fairnessRow
|
||||
|
||||
for rows.Next() {
|
||||
var r fairnessRow
|
||||
if err := rows.Scan(&r.MapID, &r.PlayerSlot, &r.Games, &r.Wins); err != nil {
|
||||
return fmt.Errorf("scan fairness row: %w", err)
|
||||
}
|
||||
stats = append(stats, r)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, s := range stats {
|
||||
_, err := m.db.ExecContext(ctx, `
|
||||
INSERT INTO map_fairness (map_id, player_slot, games, wins, last_check)
|
||||
VALUES ($1, $2, $3, $4, NOW())
|
||||
ON CONFLICT (map_id, player_slot) DO UPDATE
|
||||
SET games = $3, wins = $4, last_check = NOW()
|
||||
`, s.MapID, s.PlayerSlot, s.Games, s.Wins)
|
||||
if err != nil {
|
||||
log.Printf("fairness-audit: update map_fairness for %s slot %d: %v", s.MapID, s.PlayerSlot, err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(stats) > 0 {
|
||||
log.Printf("fairness-audit: updated fairness stats for %d map-slot pairs", len(stats))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// flagUnfairMaps sets status='probation' for maps where any player slot's
|
||||
// win rate deviates from expected (1/N for N-player) by more than 10pp
|
||||
// across 80+ completed matches.
|
||||
func (m *Matchmaker) flagUnfairMaps(ctx context.Context) error {
|
||||
// Find maps where any slot has >=80 games and win rate deviation > 10pp.
|
||||
rows, err := m.db.QueryContext(ctx, `
|
||||
WITH slot_rates AS (
|
||||
SELECT
|
||||
mf.map_id,
|
||||
mf.player_slot,
|
||||
mf.games,
|
||||
mf.wins,
|
||||
mf.wins::float / NULLIF(mf.games, 0) AS win_rate,
|
||||
map.player_count,
|
||||
1.0 / map.player_count AS expected_rate
|
||||
FROM map_fairness mf
|
||||
JOIN maps map ON map.map_id = mf.map_id
|
||||
WHERE map.status = 'active'
|
||||
AND mf.games >= $1
|
||||
),
|
||||
unfair AS (
|
||||
SELECT DISTINCT map_id
|
||||
FROM slot_rates
|
||||
WHERE ABS(win_rate - expected_rate) > $2
|
||||
)
|
||||
SELECT map_id FROM unfair
|
||||
`, fairnessMinGames, fairnessThresholdPP)
|
||||
if err != nil {
|
||||
return fmt.Errorf("query unfair maps: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var flagged []string
|
||||
for rows.Next() {
|
||||
var mapID string
|
||||
if err := rows.Scan(&mapID); err != nil {
|
||||
return err
|
||||
}
|
||||
flagged = append(flagged, mapID)
|
||||
}
|
||||
|
||||
for _, mapID := range flagged {
|
||||
_, err := m.db.ExecContext(ctx, `
|
||||
UPDATE maps SET status = 'probation' WHERE map_id = $1 AND status = 'active'
|
||||
`, mapID)
|
||||
if err != nil {
|
||||
log.Printf("fairness-audit: failed to flag %s as probation: %v", mapID, err)
|
||||
continue
|
||||
}
|
||||
log.Printf("fairness-audit: flagged map %s as probation (positional unfairness detected)", mapID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// retireDislikedMaps force-retires maps with >20 net negative votes,
|
||||
// regardless of engagement score.
|
||||
func (m *Matchmaker) retireDislikedMaps(ctx context.Context) error {
|
||||
result, err := m.db.ExecContext(ctx, `
|
||||
UPDATE maps m SET
|
||||
status = 'retired',
|
||||
retired_at = NOW()
|
||||
FROM (
|
||||
SELECT map_id, SUM(vote)::int AS net_votes
|
||||
FROM map_votes
|
||||
GROUP BY map_id
|
||||
HAVING SUM(vote) < $1
|
||||
) v
|
||||
WHERE m.map_id = v.map_id
|
||||
AND m.status IN ('active', 'probation')
|
||||
`, voteForceRetireThreshold)
|
||||
if err != nil {
|
||||
return fmt.Errorf("retire disliked maps: %w", err)
|
||||
}
|
||||
|
||||
affected, _ := result.RowsAffected()
|
||||
if affected > 0 {
|
||||
log.Printf("fairness-audit: force-retired %d map(s) with <%d net votes", affected, voteForceRetireThreshold)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pruneLowEngagementMaps retires the bottom 10% of active maps by rolling
|
||||
// average engagement score, run once per month (checked by day-of-month).
|
||||
func (m *Matchmaker) pruneLowEngagementMaps(ctx context.Context) error {
|
||||
// Only run on the 1st of each month.
|
||||
if time.Now().Day() != 1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if we already pruned this month.
|
||||
var prunedThisMonth int
|
||||
err := m.db.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*) FROM maps
|
||||
WHERE retired_at >= DATE_TRUNC('month', CURRENT_DATE)
|
||||
AND retired_at IS NOT NULL
|
||||
`).Scan(&prunedThisMonth)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check monthly prune: %w", err)
|
||||
}
|
||||
if prunedThisMonth > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Compute engagement from map_scores rolling average, falling back to maps.engagement.
|
||||
// Count active maps per player_count, then prune bottom 10% within each tier.
|
||||
for _, pc := range []int{2, 3, 4, 6} {
|
||||
var totalActive int
|
||||
err := m.db.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*) FROM maps
|
||||
WHERE player_count = $1 AND status = 'active'
|
||||
`, pc).Scan(&totalActive)
|
||||
if err != nil || totalActive < 10 {
|
||||
continue
|
||||
}
|
||||
|
||||
toPrune := int(math.Ceil(float64(totalActive) * engagementPrunePct))
|
||||
if toPrune < 1 {
|
||||
continue
|
||||
}
|
||||
|
||||
result, err := m.db.ExecContext(ctx, `
|
||||
UPDATE maps m SET
|
||||
status = 'retired',
|
||||
retired_at = NOW()
|
||||
FROM (
|
||||
SELECT map_id FROM maps
|
||||
WHERE player_count = $1 AND status = 'active'
|
||||
ORDER BY engagement ASC
|
||||
LIMIT $2
|
||||
) sub
|
||||
WHERE m.map_id = sub.map_id
|
||||
`, pc, toPrune)
|
||||
if err != nil {
|
||||
log.Printf("fairness-audit: prune engagement error for player_count=%d: %v", pc, err)
|
||||
continue
|
||||
}
|
||||
|
||||
affected, _ := result.RowsAffected()
|
||||
if affected > 0 {
|
||||
log.Printf("fairness-audit: pruned %d/%d low-engagement maps for %d-player tier", affected, totalActive, pc)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// promoteClassicMaps promotes maps that have been in the top-5 engagement
|
||||
// for their player count for 3+ months to 'classic' status, making them
|
||||
// immune from retirement.
|
||||
func (m *Matchmaker) promoteClassicMaps(ctx context.Context) error {
|
||||
for _, pc := range []int{2, 3, 4, 6} {
|
||||
result, err := m.db.ExecContext(ctx, `
|
||||
UPDATE maps m SET status = 'classic'
|
||||
FROM (
|
||||
SELECT map_id FROM maps
|
||||
WHERE player_count = $1
|
||||
AND status = 'active'
|
||||
AND engagement > 0
|
||||
AND created_at < NOW() - INTERVAL '3 months'
|
||||
ORDER BY engagement DESC
|
||||
LIMIT $2
|
||||
) sub
|
||||
WHERE m.map_id = sub.map_id
|
||||
`, pc, classicTopN)
|
||||
if err != nil {
|
||||
log.Printf("fairness-audit: promote classic error for player_count=%d: %v", pc, err)
|
||||
continue
|
||||
}
|
||||
|
||||
affected, _ := result.RowsAffected()
|
||||
if affected > 0 {
|
||||
log.Printf("fairness-audit: promoted %d map(s) to classic for %d-player tier", affected, pc)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
277
cmd/acb-matchmaker/map_fairness_test.go
Normal file
277
cmd/acb-matchmaker/map_fairness_test.go
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFairnessThresholdCalculation(t *testing.T) {
|
||||
// For N-player maps, expected win rate is 1/N.
|
||||
// A slot is flagged unfair if its win rate deviates by > 10pp.
|
||||
tests := []struct {
|
||||
name string
|
||||
playerCount int
|
||||
winRate float64
|
||||
shouldFlag bool
|
||||
}{
|
||||
{"2-player exact 50%", 2, 0.50, false},
|
||||
{"2-player 59%", 2, 0.59, false},
|
||||
{"2-player 60%", 2, 0.60, false},
|
||||
{"2-player 61%", 2, 0.61, true},
|
||||
{"2-player 39%", 2, 0.39, true},
|
||||
{"2-player 38%", 2, 0.38, true},
|
||||
{"2-player 37%", 2, 0.37, true},
|
||||
{"3-player exact 33%", 3, 1.0 / 3.0, false},
|
||||
{"3-player 44%", 3, 0.44, true},
|
||||
{"3-player 22%", 3, 0.22, true},
|
||||
{"4-player exact 25%", 4, 0.25, false},
|
||||
{"4-player 36%", 4, 0.36, true},
|
||||
{"4-player 14%", 4, 0.14, true},
|
||||
{"6-player exact 16.7%", 6, 1.0 / 6.0, false},
|
||||
{"6-player 27%", 6, 0.27, true},
|
||||
{"6-player 6%", 6, 0.06, true},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
expected := 1.0 / float64(tc.playerCount)
|
||||
deviation := math.Abs(tc.winRate - expected)
|
||||
shouldFlag := deviation > fairnessThresholdPP
|
||||
if shouldFlag != tc.shouldFlag {
|
||||
t.Errorf("playerCount=%d winRate=%.2f: deviation=%.4f, shouldFlag=%v, want %v",
|
||||
tc.playerCount, tc.winRate, deviation, shouldFlag, tc.shouldFlag)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFairnessMinGamesThreshold(t *testing.T) {
|
||||
// Only maps with >= 80 matches per slot are evaluated.
|
||||
tests := []struct {
|
||||
games int
|
||||
shouldEval bool
|
||||
}{
|
||||
{0, false},
|
||||
{1, false},
|
||||
{79, false},
|
||||
{80, true},
|
||||
{100, true},
|
||||
{1000, true},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
shouldEval := tc.games >= fairnessMinGames
|
||||
if shouldEval != tc.shouldEval {
|
||||
t.Errorf("games=%d: shouldEval=%v, want %v", tc.games, shouldEval, tc.shouldEval)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoteForceRetireThreshold(t *testing.T) {
|
||||
// Maps with >20 net negative votes are force-retired.
|
||||
tests := []struct {
|
||||
netVotes int
|
||||
shouldRetire bool
|
||||
}{
|
||||
{-25, true},
|
||||
{-21, true},
|
||||
{-20, false},
|
||||
{-19, false},
|
||||
{-10, false},
|
||||
{0, false},
|
||||
{10, false},
|
||||
{50, false},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
shouldRetire := tc.netVotes < voteForceRetireThreshold
|
||||
if shouldRetire != tc.shouldRetire {
|
||||
t.Errorf("netVotes=%d: shouldRetire=%v, want %v", tc.netVotes, shouldRetire, tc.shouldRetire)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngagementPrunePercentage(t *testing.T) {
|
||||
// Bottom 10% are pruned monthly per player-count tier.
|
||||
tests := []struct {
|
||||
totalActive int
|
||||
wantPruned int
|
||||
}{
|
||||
{5, 0}, // too few to prune
|
||||
{10, 1},
|
||||
{20, 2},
|
||||
{50, 5},
|
||||
{100, 10},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
toPrune := int(math.Ceil(float64(tc.totalActive) * engagementPrunePct))
|
||||
if tc.totalActive < 10 {
|
||||
toPrune = 0 // logic skips tiers with <10 maps
|
||||
}
|
||||
if toPrune != tc.wantPruned {
|
||||
t.Errorf("totalActive=%d: pruned=%d, want %d", tc.totalActive, toPrune, tc.wantPruned)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassicPromotionCriteria(t *testing.T) {
|
||||
// Maps must be active, have engagement > 0, be 3+ months old,
|
||||
// and be in the top 5 by engagement for their player count.
|
||||
tests := []struct {
|
||||
name string
|
||||
engagement float64
|
||||
ageMonths int
|
||||
status string
|
||||
shouldPromote bool
|
||||
}{
|
||||
{"meets all criteria", 8.5, 4, "active", true},
|
||||
{"too young", 9.0, 2, "active", false},
|
||||
{"zero engagement", 0.0, 6, "active", false},
|
||||
{"already classic", 9.0, 6, "classic", false},
|
||||
{"on probation", 7.0, 4, "probation", false},
|
||||
{"exactly 3 months", 7.0, 3, "active", true},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
isEligible := tc.status == "active" && tc.engagement > 0 && tc.ageMonths >= classicMinMonths
|
||||
if isEligible != tc.shouldPromote {
|
||||
t.Errorf("engagement=%.1f ageMonths=%d status=%s: eligible=%v, want %v",
|
||||
tc.engagement, tc.ageMonths, tc.status, isEligible, tc.shouldPromote)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFairnessAuditConfigDefault(t *testing.T) {
|
||||
cfg := loadConfig()
|
||||
if cfg.FairnessAuditSecs != 3600 {
|
||||
t.Errorf("FairnessAuditSecs default: got %d, want 3600", cfg.FairnessAuditSecs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFairnessAuditConfigOverride(t *testing.T) {
|
||||
t.Setenv("ACB_FAIRNESS_AUDIT_INTERVAL", "7200")
|
||||
cfg := loadConfig()
|
||||
if cfg.FairnessAuditSecs != 7200 {
|
||||
t.Errorf("FairnessAuditSecs override: got %d, want 7200", cfg.FairnessAuditSecs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonthlyPruneOnlyOnFirst(t *testing.T) {
|
||||
// pruneLowEngagementMaps only runs on the 1st of each month.
|
||||
tests := []struct {
|
||||
day int
|
||||
run bool
|
||||
}{
|
||||
{1, true},
|
||||
{2, false},
|
||||
{15, false},
|
||||
{28, false},
|
||||
{31, false},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run("", func(t *testing.T) {
|
||||
shouldRun := tc.day == 1
|
||||
if shouldRun != tc.run {
|
||||
t.Errorf("day=%d: shouldRun=%v, want %v", tc.day, shouldRun, tc.run)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassicTopN(t *testing.T) {
|
||||
if classicTopN != 5 {
|
||||
t.Errorf("classicTopN: got %d, want 5", classicTopN)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassicMinMonths(t *testing.T) {
|
||||
if classicMinMonths != 3 {
|
||||
t.Errorf("classicMinMonths: got %d, want 3", classicMinMonths)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFairnessAuditStepOrder(t *testing.T) {
|
||||
// Verify the ordering of steps in tickFairnessAudit:
|
||||
// 1. updateMapFairnessStats (recompute from match data)
|
||||
// 2. flagUnfairMaps (probation for unfair maps)
|
||||
// 3. retireDislikedMaps (force-retire by votes)
|
||||
// 4. pruneLowEngagementMaps (monthly bottom 10%)
|
||||
// 5. promoteClassicMaps (top-5 sustained engagement)
|
||||
//
|
||||
// This ordering matters because:
|
||||
// - Stats must be current before fairness checks
|
||||
// - Probation must happen before retirement (probation is a warning)
|
||||
// - Vote retirement is independent of engagement
|
||||
// - Classic promotion should happen after pruning (so promoted maps
|
||||
// are truly immune)
|
||||
steps := []string{
|
||||
"updateMapFairnessStats",
|
||||
"flagUnfairMaps",
|
||||
"retireDislikedMaps",
|
||||
"pruneLowEngagementMaps",
|
||||
"promoteClassicMaps",
|
||||
}
|
||||
|
||||
if len(steps) != 5 {
|
||||
t.Errorf("expected 5 fairness audit steps, got %d", len(steps))
|
||||
}
|
||||
if steps[0] != "updateMapFairnessStats" {
|
||||
t.Errorf("step 0 should be updateMapFairnessStats, got %s", steps[0])
|
||||
}
|
||||
if steps[1] != "flagUnfairMaps" {
|
||||
t.Errorf("step 1 should be flagUnfairMaps, got %s", steps[1])
|
||||
}
|
||||
if steps[4] != "promoteClassicMaps" {
|
||||
t.Errorf("step 4 should be promoteClassicMaps, got %s", steps[4])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbationDoesNotAffectClassic(t *testing.T) {
|
||||
// Classic maps should never be moved to probation.
|
||||
// The flagUnfairMaps query only targets status='active'.
|
||||
status := "classic"
|
||||
canFlag := status == "active"
|
||||
if canFlag {
|
||||
t.Errorf("classic maps should not be flaggable as probation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngagementPruneSkipTierWithFewMaps(t *testing.T) {
|
||||
// Tiers with < 10 active maps should not be pruned.
|
||||
for _, totalActive := range []int{0, 1, 5, 9} {
|
||||
shouldSkip := totalActive < 10
|
||||
if !shouldSkip {
|
||||
t.Errorf("totalActive=%d should be skipped for pruning", totalActive)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestThreeMonthAgeCheck(t *testing.T) {
|
||||
// created_at must be >= 3 months ago for classic promotion.
|
||||
now := time.Now()
|
||||
tests := []struct {
|
||||
createdAgo time.Duration
|
||||
eligible bool
|
||||
}{
|
||||
{30 * 24 * time.Hour, false}, // 1 month
|
||||
{89 * 24 * time.Hour, false}, // ~3 months minus 1 day
|
||||
{90 * 24 * time.Hour, true}, // 3 months
|
||||
{180 * 24 * time.Hour, true}, // 6 months
|
||||
{365 * 24 * time.Hour, true}, // 1 year
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
createdAt := now.Add(-tc.createdAgo)
|
||||
// Use a simpler check: created_at < NOW() - 3 months
|
||||
cutoff := now.AddDate(0, -classicMinMonths, 0)
|
||||
eligibleByDate := createdAt.Before(cutoff)
|
||||
if eligibleByDate != tc.eligible {
|
||||
t.Errorf("created %v ago: eligible=%v, want %v", tc.createdAgo, eligibleByDate, tc.eligible)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue