Pets: roll arrival on expedition emergence, not the 08:00 morning DM

The pet-arrival roll only ran in sendMorningDMs (the 08:00 overworld
morning DM), which is skipped for anyone underground. Expedition players
are almost never in the overworld at 08:00, so the roll never reached
them — the encounter never fired despite the conditions being met.

Move the roll onto the emergence seam via a shared helper
maybeRollPetArrivalOnEmerge, called when a player surfaces alive
(voluntary extract, abandon, survived forced extraction) and on respawn
for underground deaths. Remove the now-dead morning-DM arrival roll.
Story-wise: while the player was out, an animal wandered into the empty
house looking for food.

The 25% morning pet event still rolls only in the overworld DM and has
the same reachability gap; left for a follow-up.
This commit is contained in:
prosolis
2026-05-21 22:51:00 -07:00
parent 72f4ef3b27
commit 3ed2e1d8e0
5 changed files with 52 additions and 9 deletions

View File

@@ -206,6 +206,25 @@ func petShouldArrive(pet PetState, house HouseState) bool {
return rand.Float64() < 0.30 return rand.Float64() < 0.30
} }
// maybeRollPetArrivalOnEmerge rolls the pet-arrival check when a player
// surfaces from an expedition (voluntary extract, abandon, or a survived
// forced extraction) or revives after death. The arrival roll lives on the
// emergence seam — not the legacy 08:00 overworld morning DM — because
// expedition players are almost never in the overworld at the scheduled hour,
// so the morning roll never reached them. Story beat: while the player was
// underground, an animal wandered into the empty house looking for food.
//
// Safe to call unconditionally on any emergence: petShouldArrive gates on
// house tier / not-yet-arrived, and petArrivalDM won't clobber an existing
// pending interaction.
func (p *AdventurePlugin) maybeRollPetArrivalOnEmerge(userID id.UserID) {
pet, _ := loadPetState(userID)
house, _ := loadHouseState(userID)
if petShouldArrive(pet, house) {
p.petArrivalDM(userID)
}
}
// petArrivalDM sends the initial "there's an animal in your house" DM. // petArrivalDM sends the initial "there's an animal in your house" DM.
func (p *AdventurePlugin) petArrivalDM(userID id.UserID) { func (p *AdventurePlugin) petArrivalDM(userID id.UserID) {
// Don't overwrite an existing pending interaction // Don't overwrite an existing pending interaction

View File

@@ -82,6 +82,12 @@ func (p *AdventurePlugin) sendMorningDMs() {
if err := p.SendDM(char.UserID, text); err != nil { if err := p.SendDM(char.UserID, text); err != nil {
slog.Error("adventure: failed to send respawn DM", "user", char.UserID, "err", err) slog.Error("adventure: failed to send respawn DM", "user", char.UserID, "err", err)
} }
// Emergence seam (death case): a player who died underground
// "comes home" on respawn. This is the deferred half of the
// emergence roll — survived extractions roll at their exit
// site; deaths roll here. See maybeRollPetArrivalOnEmerge.
p.maybeRollPetArrivalOnEmerge(char.UserID)
} }
// Babysitting: pet-care trickle (no harvest actions; safe-rest perk // Babysitting: pet-care trickle (no harvest actions; safe-rest perk
@@ -133,13 +139,12 @@ func (p *AdventurePlugin) sendMorningDMs() {
continue continue
} }
// Pet arrival check (fires before normal morning DM) // Pet arrival no longer rolls here. The 08:00 overworld morning DM
house, _ := loadHouseState(char.UserID) // is skipped for anyone underground (expedition gate above), so it
// never reached expedition players. Arrival now fires on the
// emergence seam — see maybeRollPetArrivalOnEmerge, called from the
// extract/abandon/forced-extract and respawn paths.
pet, _ := loadPetState(char.UserID) pet, _ := loadPetState(char.UserID)
if petShouldArrive(pet, house) {
p.petArrivalDM(char.UserID)
continue
}
// Morning pet event // Morning pet event
petEvent := petMorningEvent(pet) petEvent := petMorningEvent(pet)

View File

@@ -486,9 +486,14 @@ func (p *AdventurePlugin) expeditionCmdAbandon(ctx MessageContext) error {
} }
_ = retireAllRegionRuns(exp) _ = retireAllRegionRuns(exp)
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative", "expedition abandoned", "") _ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative", "expedition abandoned", "")
return p.SendDM(ctx.Sender, fmt.Sprintf( if err := p.SendDM(ctx.Sender, fmt.Sprintf(
"Expedition in **%s** abandoned on Day %d. Supplies are forfeit. The dungeon remembers.", "Expedition in **%s** abandoned on Day %d. Supplies are forfeit. The dungeon remembers.",
zone.Display, exp.CurrentDay)) zone.Display, exp.CurrentDay)); err != nil {
return err
}
// Emergence seam: see maybeRollPetArrivalOnEmerge.
p.maybeRollPetArrivalOnEmerge(ctx.Sender)
return nil
} }
// helper: ensure we don't shadow id.UserID import in test harness. // helper: ensure we don't shadow id.UserID import in test harness.

View File

@@ -303,6 +303,14 @@ func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error {
if err := p.SendDM(uid, body); err != nil { if err := p.SendDM(uid, body); err != nil {
slog.Warn("expedition: send briefing DM", "user", uid, "err", err) slog.Warn("expedition: send briefing DM", "user", uid, "err", err)
} }
// Emergence seam: a briefing-time forced extraction (starvation /
// abyss collapse) surfaces the player alive — roll pet arrival.
// Combat/patrol deaths never reach deliverBriefing (the row is
// already abandoned), so an abandoned status here means a survived
// emergence; those death paths roll on respawn instead.
if e.Status == ExpeditionStatusAbandoned {
p.maybeRollPetArrivalOnEmerge(uid)
}
} }
if err := appendExpeditionLog(e.ID, e.CurrentDay, "briefing", if err := appendExpeditionLog(e.ID, e.CurrentDay, "briefing",
fmt.Sprintf("morning briefing — %.1f SU consumed overnight", burn), line); err != nil { fmt.Sprintf("morning briefing — %.1f SU consumed overnight", burn), line); err != nil {

View File

@@ -179,7 +179,13 @@ func (p *AdventurePlugin) handleExtractCmd(ctx MessageContext, _ string) error {
} }
b.WriteString(fmt.Sprintf("Loot, XP, and coins are kept. The dungeon stays where you left it — `!resume` within 7 days to come back. After %s the expedition expires.", b.WriteString(fmt.Sprintf("Loot, XP, and coins are kept. The dungeon stays where you left it — `!resume` within 7 days to come back. After %s the expedition expires.",
(time.Now().UTC().Add(extractResumeWindow)).Format("2006-01-02 15:04 MST"))) (time.Now().UTC().Add(extractResumeWindow)).Format("2006-01-02 15:04 MST")))
return p.SendDM(ctx.Sender, b.String()) if err := p.SendDM(ctx.Sender, b.String()); err != nil {
return err
}
// Emergence seam: surfacing from a run is when an animal may have moved
// into the empty house.
p.maybeRollPetArrivalOnEmerge(ctx.Sender)
return nil
} }
// ── !resume command ───────────────────────────────────────────────────────── // ── !resume command ─────────────────────────────────────────────────────────