Pet arrival: fire after run narration, not before

The run-complete emergence seam rolled the pet-arrival DM synchronously,
then handed the run narration to streamFlow's paced (fire-and-forget)
streamer. The 'animal in your house' prompt landed ahead of the queued
'Run complete' + loot beats. Add streamFlowThen to run the roll after the
final message is delivered; fix the same inverted order in tryAutoRun.
This commit is contained in:
prosolis
2026-05-22 07:10:51 -07:00
parent ef8fbe5496
commit 56f896b941
3 changed files with 39 additions and 11 deletions

View File

@@ -560,11 +560,15 @@ func (p *AdventurePlugin) expeditionCmdRun(ctx MessageContext) error {
// surfaces the player alive just like an extract or abandon — roll pet
// arrival here too. The roll lives in the real callers, not in
// runAutopilotWalk, so the sim path (which calls the walk directly)
// never fires arrival DMs. See maybeRollPetArrivalOnEmerge.
// never fires arrival DMs. See maybeRollPetArrivalOnEmerge. Defer it
// behind the paced stream so the "animal in your house" DM lands after
// the "Run complete" beat, not before it.
var after func()
if r.reason == stopComplete {
p.maybeRollPetArrivalOnEmerge(ctx.Sender)
uid := ctx.Sender
after = func() { p.maybeRollPetArrivalOnEmerge(uid) }
}
return p.streamFlow(ctx.Sender, r.stream, r.finalMsg)
return p.streamFlowThen(ctx.Sender, r.stream, r.finalMsg, after)
}
// runAutopilotWalk runs the autopilot loop up to maxRooms times and

View File

@@ -817,6 +817,30 @@ func (p *AdventurePlugin) streamFlow(userID id.UserID, phaseMessages []string, f
return nil
}
// streamFlowThen behaves like streamFlow but runs after() once the final
// message has actually been delivered. Emergence follow-ups (e.g. the pet
// arrival DM) must land strictly *after* the paced run narration — rolling
// them before streamFlow returns races the streamer's goroutine and surfaces
// "there's an animal in your house" ahead of the "Run complete" beat the
// player is still waiting on. after may be nil.
func (p *AdventurePlugin) streamFlowThen(userID id.UserID, phaseMessages []string, finalMessage string, after func()) error {
if len(phaseMessages) == 0 {
err := p.SendDM(userID, finalMessage)
if after != nil {
after()
}
return err
}
done := p.sendZoneCombatMessages(userID, phaseMessages, finalMessage)
if after != nil {
go func() {
<-done
after()
}()
}
return nil
}
// resolveRoom dispatches to the per-room-type resolver. Returns staged
// messages (intro, phases, outcome) so combat rooms can be paced with
// inter-phase delays — see resolveCombatRoom for the contract. For

View File

@@ -165,17 +165,17 @@ func (p *AdventurePlugin) tryAutoRun(e *Expedition, now time.Time) error {
}
// Emergence seam: a run-complete reached by the background ticker is
// still a live emergence — roll pet arrival. See maybeRollPetArrivalOnEmerge.
if r.reason == stopComplete {
p.maybeRollPetArrivalOnEmerge(uid)
}
if !shouldDMAutoRun(r) {
return nil
}
// Deferred until after the run-summary DM below so the "animal in your
// house" prompt lands after the summary, not ahead of it.
if shouldDMAutoRun(r) {
body := renderAutoRunDM(r)
if err := p.SendDM(uid, body); err != nil {
slog.Warn("expedition: autorun DM", "user", uid, "err", err)
}
}
if r.reason == stopComplete {
p.maybeRollPetArrivalOnEmerge(uid)
}
return nil
}