diff --git a/internal/plugin/adventure_events.go b/internal/plugin/adventure_events.go index f250a3f..26bf275 100644 --- a/internal/plugin/adventure_events.go +++ b/internal/plugin/adventure_events.go @@ -32,10 +32,23 @@ type advActiveEvent struct { // // The per-anchor chances below put a player who hits all three at ~1 event // per week; see TestAnchoredEventWeeklyRate. +// +// K — the three social/economic anchors miss the pure grind loop: 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 events. The +// fourth anchor, ZoneClear, fires on a foreground single-day zone clear — the +// climax DM that player *is* reading — so they get the same rough cadence. +// Autopilot walks and mid-zone region clears deliberately do not fire it (see +// zoneCmdAdvance / the full-clear branch of advanceOnceWithOpts). The per-day +// slot still caps everyone at one event, so a player who happens to hit two +// anchors in a day can't double up. This rate is the tuning knob for item K — +// bump it if telemetry shows the grind-loop player still sees too few. const ( - advEventChanceDigest = 0.08 - advEventChanceSell = 0.05 - advEventChanceArena = 0.05 + advEventChanceDigest = 0.08 + advEventChanceSell = 0.05 + advEventChanceArena = 0.05 + advEventChanceZoneClear = 0.08 ) var ( diff --git a/internal/plugin/adventure_events_test.go b/internal/plugin/adventure_events_test.go index e65d5b8..bd85469 100644 --- a/internal/plugin/adventure_events_test.go +++ b/internal/plugin/adventure_events_test.go @@ -44,6 +44,43 @@ func TestAnchoredEventWeeklyRate(t *testing.T) { } } +// 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") diff --git a/internal/plugin/dnd_zone_cmd.go b/internal/plugin/dnd_zone_cmd.go index b196c78..b104422 100644 --- a/internal/plugin/dnd_zone_cmd.go +++ b/internal/plugin/dnd_zone_cmd.go @@ -471,6 +471,12 @@ type advanceResult struct { // harvest ran (Entry/Trap/Elite/Boss rooms, forks, stops). harvest autoHarvestResult harvestFooter string + // zoneCleared is true only when this step was a *full* zone clear + // (reason == stopComplete and not a mid-zone region clear). The K + // event anchor reads it so a foreground `!zone advance` clear can roll + // the grind-loop player's mid-day event — a mid-zone region clear does + // not, since the run continues and it would fire per region. + zoneCleared bool } // zoneCmdAdvance resolves the room the player is currently standing in, @@ -495,7 +501,16 @@ func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error { if err != nil { return p.SendDM(ctx.Sender, err.Error()) } - return p.streamOrSend(ctx.Sender, res.preStream, res.intro, res.phases, res.final) + sendErr := p.streamOrSend(ctx.Sender, res.preStream, res.intro, res.phases, res.final) + // K fourth event anchor: a foreground single-day zone clear is a presence + // moment for the grind-loop player the other three anchors miss. Rolled + // after the clear DM so any triggered event lands behind it, and only here + // — zoneCmdAdvance is the foreground path; autopilot walks go through + // runAutopilotWalk and never reach this. + if res.zoneCleared { + p.maybeFireAnchoredEvent(ctx.Sender, advEventChanceZoneClear) + } + return sendErr } // advanceOnce runs the single-room advance pipeline and returns a @@ -737,7 +752,8 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact, inlin // and point at the next one — "Cleared {zone}. Run complete." reads // wrong right before the auto-advance transit block (and is shared with // manual `!region travel`, which advances next). - if region, next, midZone := midZoneRegionClear(ctx.Sender, run.RunID); midZone { + region, next, midZone := midZoneRegionClear(ctx.Sender, run.RunID) + if midZone { b.WriteString(fmt.Sprintf("🏁 **Cleared %s.** The way to %s opens ahead.\n\n", region.Name, next.Name)) } else { b.WriteString(fmt.Sprintf("🏆 **Cleared %s.** Run complete.\n\n", zone.Display)) @@ -763,11 +779,12 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact, inlin } } return advanceResult{ - preStream: preStream, - intro: intro, - phases: phases, - final: b.String(), - reason: stopComplete, + preStream: preStream, + intro: intro, + phases: phases, + final: b.String(), + reason: stopComplete, + zoneCleared: !midZone, }, nil } if forkMsg != "" {