Files
gogobee/internal/plugin/adventure_events_test.go
prosolis 6be7744e81 Review follow-up K: a fourth event anchor for the grind loop
The three A6 presence anchors (expedition Night digest, !sell, arena
cashout) miss a whole playstyle: a player who only mines/forages/fishes
(now automatic, done by walking) and clears single-day dungeons, but
never sells, never enters the arena, and never runs a multi-day
expedition to a Night camp, would roll for zero mid-day events.

Anchor a fourth roll on a foreground single-day zone clear — the climax
DM that player is reading, and one they cannot spam (a clear costs a
full walk). advanceResult.zoneCleared is set only in the full-clear
branch (a mid-zone region clear continues the run and would fire per
region), and the roll lives in zoneCmdAdvance, the foreground path only:
autopilot goes through runAutopilotWalk and party members are refused
earlier by isPartyMember. The per-day slot guard still caps everyone at
one event/day, so the three prior anchors are unmoved.

advEventChanceZoneClear = 0.08 puts a zone-clear-only player at ~1
event/week, matching the fully-engaged player; it is the tuning knob.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 09:12:34 -07:00

121 lines
3.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.81.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.81.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")
}
}