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
This commit is contained in:
prosolis
2026-07-10 09:12:34 -07:00
parent 1f1fbf0251
commit 6be7744e81
3 changed files with 77 additions and 10 deletions

View File

@@ -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 (

View File

@@ -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.81.5)", perWeek)
}
}
// TestClaimDailyEventSlot_OnePerDay guards the cap the rate test assumes.
func TestClaimDailyEventSlot_OnePerDay(t *testing.T) {
uid := id.UserID("@events-slot:example")

View File

@@ -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 != "" {