package plugin import ( "math/rand/v2" "testing" "maunium.net/go/mautrix/id" ) // TestAnchoredEventWeeklyRate pins the A6 rebalance: a player who reads an // end-of-day digest, sells at Thom's, and cashes out of the arena every day // should see roughly one mid-day event per week. The old 0.5%/day roll put // that at one per ~200 days. // // The simulation drives its own seeded RNG rather than the package-global // one, so it measures the policy — the chance constants and the one-event- // per-day cap — and never flakes on RNG ordering elsewhere in the suite. func TestAnchoredEventWeeklyRate(t *testing.T) { anchors := []float64{advEventChanceDigest, advEventChanceSell, advEventChanceArena} const weeks = 20000 rng := rand.New(rand.NewPCG(0x5EED, 0xA6)) events := 0 for range weeks { for range 7 { firedToday := false for _, chance := range anchors { if firedToday { break // one event per player per UTC day } if rng.Float64() < chance { firedToday = true } } if firedToday { events++ } } } perWeek := float64(events) / weeks if perWeek < 0.8 || perWeek > 1.5 { t.Errorf("anchored events = %.3f/week, want ~1 (0.8–1.5)", perWeek) } } // TestZoneClearAnchorRate pins item K's fourth anchor for the population the // other three miss: a grind-loop player who never sells, never enters the // arena, and never runs a multi-day expedition, but clears single-day zones. // Modelled at two foreground clears per active day (a plausible dungeon // session) they should land in the same ~1 event/week band as the fully- // engaged player — that is the whole point of adding the anchor. The clears/day // assumption and advEventChanceZoneClear are the two tuning knobs; see the K // note in adventure_events.go. func TestZoneClearAnchorRate(t *testing.T) { const clearsPerDay = 2 const weeks = 20000 rng := rand.New(rand.NewPCG(0x5EED, 0x2C)) // "ZC" events := 0 for range weeks { for range 7 { firedToday := false for range clearsPerDay { if firedToday { break // one event per player per UTC day } if rng.Float64() < advEventChanceZoneClear { firedToday = true } } if firedToday { events++ } } } perWeek := float64(events) / weeks if perWeek < 0.8 || perWeek > 1.5 { t.Errorf("zone-clear anchor = %.3f/week, want ~1 (0.8–1.5)", perWeek) } } // TestClaimDailyEventSlot_OnePerDay guards the cap the rate test assumes. func TestClaimDailyEventSlot_OnePerDay(t *testing.T) { uid := id.UserID("@events-slot:example") const day = "2026-07-09" advEventFiredMu.Lock() advEventFired = nil advEventFiredDay = "" advEventFiredMu.Unlock() if !claimDailyEventSlot(uid, day) { t.Fatal("first claim of the day should succeed") } if claimDailyEventSlot(uid, day) { t.Error("second claim on the same day should be refused") } // A trigger that bailed before reaching the player hands the slot back. releaseDailyEventSlot(uid) if !claimDailyEventSlot(uid, day) { t.Error("claim after release should succeed") } // A new UTC day resets everyone. if !claimDailyEventSlot(uid, "2026-07-10") { t.Error("claim on a new day should succeed") } } // TestClaimDailyEventSlot_PerUser — one player's event doesn't consume another's. func TestClaimDailyEventSlot_PerUser(t *testing.T) { const day = "2026-07-11" if !claimDailyEventSlot(id.UserID("@events-a:example"), day) { t.Fatal("player A should claim") } if !claimDailyEventSlot(id.UserID("@events-b:example"), day) { t.Error("player B should claim independently of A") } }