2 Commits

Author SHA1 Message Date
prosolis
2ea3e42612 Forex: reject crypto/unknown currencies on the fiat-only rate/report path
The analysis path silently normalized junk tokens (BTC/USD, XYZ) to the
default EUR/JPY/CAD set, giving no signal the input was rejected. Validate
tokens up front: crypto symbols get nudged toward the conversion path,
anything else returns an unknown-currency error.
2026-05-21 22:58:31 -07:00
prosolis
3ed2e1d8e0 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.
2026-05-21 22:51:00 -07:00
6 changed files with 87 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 ─────────────────────────────────────────────────────────

View File

@@ -122,6 +122,10 @@ const fxHelpText = "**Forex Commands**\n\n" +
// ── Command Handlers ──────────────────────────────────────────────────────── // ── Command Handlers ────────────────────────────────────────────────────────
func (p *ForexPlugin) cmdRate(ctx MessageContext, args []string) error { func (p *ForexPlugin) cmdRate(ctx MessageContext, args []string) error {
if msg := fxValidateAnalysisArgs(args); msg != "" {
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
}
// Check for cross-pair syntax like EUR/USD or USD/JPY // Check for cross-pair syntax like EUR/USD or USD/JPY
if pair := fxParsePair(args, false); pair != nil { if pair := fxParsePair(args, false); pair != nil {
return p.cmdPairRateOrReport(ctx, pair, false) return p.cmdPairRateOrReport(ctx, pair, false)
@@ -277,6 +281,9 @@ func (p *ForexPlugin) fxPerUSD(cur string, fiatRates map[string]float64) (float6
} }
func (p *ForexPlugin) cmdReport(ctx MessageContext, args []string) error { func (p *ForexPlugin) cmdReport(ctx MessageContext, args []string) error {
if msg := fxValidateAnalysisArgs(args); msg != "" {
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
}
if pair := fxParsePair(args, false); pair != nil { if pair := fxParsePair(args, false); pair != nil {
return p.cmdPairRateOrReport(ctx, pair, true) return p.cmdPairRateOrReport(ctx, pair, true)
} }
@@ -404,6 +411,34 @@ func (p *ForexPlugin) checkAlerts(rates map[string]float64) {
// ── Helpers ───────────────────────────────────────────────────────────────── // ── Helpers ─────────────────────────────────────────────────────────────────
// fxValidateAnalysisArgs vets the currency tokens passed to the fiat-only
// rate/report path. It returns a player-facing error message if any token is
// unusable here, or "" when the args are fine (including empty args, which fall
// back to the default tracked set). Crypto tokens get a dedicated nudge since
// crypto only works on the conversion path; anything else is just invalid.
func fxValidateAnalysisArgs(args []string) string {
for _, a := range args {
// Split pair syntax (BTC/USD) into its sides so each is checked.
raw := strings.ToUpper(a)
toks := []string{raw}
for _, sep := range []string{"/", "|", "-"} {
if strings.Contains(raw, sep) {
toks = strings.SplitN(raw, sep, 2)
break
}
}
for _, t := range toks {
if fxIsCrypto(t) {
return "Silly rabbit. Crypto is for kids! (In other words.. that's an invalid command — crypto only works for conversions like `!fx 100 USD to BTC`.)"
}
if !fxIsSupported(t) {
return fmt.Sprintf("Don't know the currency `%s`. Try `!fx help`.", t)
}
}
}
return ""
}
func fxParseCurrencies(args []string) []string { func fxParseCurrencies(args []string) []string {
var out []string var out []string
for _, a := range args { for _, a := range args {