N7/E4: Seasonal events — 1-week holiday skins

Four anchor holidays (Hallowtide, Midwinter Feast, Sweethearts' Revel,
First Bloom) each get a 7-day window (anchor ±3 days) that layers three
things on the world, all reusing existing machinery:

  1. A themed Omen that overrides the weekly rotation. Reuses the B3 omen
     effect fields, so the non-combat rule holds; kept behind activeOmen's
     simOmenDisabled guard (and activeSeason honours it too) so no season
     path can reach the balance sim or move the golden.
  2. A curated curio shelf at Luigi's — existing registry items rotated to
     the front of dailyCuriosStock, so no net-new power enters the economy.
     Off-season output is byte-identical to before.
  3. A themed road visitor via the ambient seam — a season_visitor event
     that leaves a sellable keepsake + coin gift. No combat: the ambient
     seam still never opens a fight.

Pure function of the UTC date + anchor calendar — no schema, ticker, or
persistence, same discipline as the Omen and holiday calendar. Season
banner rides the existing morning DM (no net-new scheduled message).

go test ./internal/plugin ./internal/db green; combat golden byte-identical.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
This commit is contained in:
prosolis
2026-07-10 20:30:15 -07:00
parent 1a47a2fdee
commit a6f1de4e74
6 changed files with 414 additions and 7 deletions

View File

@@ -230,7 +230,7 @@ func ambientEventMonologue() ambientEvent {
// it's cheap to test in isolation and free of package-level state.
func ambientEvents() []ambientEvent {
always := func(*Expedition) bool { return true }
return []ambientEvent{
events := []ambientEvent{
ambientEventMonologue(),
{
Kind: "small_find",
@@ -280,6 +280,19 @@ func ambientEvents() []ambientEvent {
},
},
}
// N7/E4 — a live season adds a themed road visitor. Non-combat: it leaves a
// keepsake and a coin gift, resolved in applyAmbientEffect. The pool and
// reward come from activeSeason(); the ambient seam is production-only, so
// this never reaches the balance sim.
if s, ok := activeSeason(); ok && len(s.Visitor.FlavorPool) > 0 {
events = append(events, ambientEvent{
Kind: "season_visitor",
Pool: s.Visitor.FlavorPool,
Weight: s.Visitor.Weight,
Eligible: func(*Expedition) bool { return true },
})
}
return events
}
// pickAmbientEvent runs a weighted pick over eligible events. avoidKind
@@ -395,6 +408,39 @@ func (p *AdventurePlugin) applyAmbientEffect(e *Expedition, ev ambientEvent) str
return ""
}
return "+1 HP"
case "season_visitor":
// N7/E4 — the visitor leaves a sellable keepsake and a coin gift. Re-read
// the season so the reward matches the current window even if it turned
// over between pick and apply; bail quietly if it just ended.
s, ok := activeSeason()
if !ok {
return ""
}
v := s.Visitor
uid := id.UserID(e.UserID)
if err := addAdvInventoryItem(uid, AdvItem{
Name: v.Keepsake,
Type: "trophy",
Tier: 1,
Value: v.KeepsakeValue,
}); err != nil {
slog.Warn("expedition: ambient season keepsake", "user", uid, "err", err)
return ""
}
if v.Coins > 0 {
if p.euro != nil {
p.euro.Credit(uid, float64(v.Coins), "expedition ambient: "+s.Key+" visitor")
}
if _, err := db.Get().Exec(`
UPDATE dnd_expedition
SET coins_earned = coins_earned + ?,
last_activity = CURRENT_TIMESTAMP
WHERE expedition_id = ?`, v.Coins, e.ID); err != nil {
slog.Warn("expedition: ambient season coin tally", "expedition", e.ID, "err", err)
}
return fmt.Sprintf("You keep the %s. +%d coins", v.Keepsake, v.Coins)
}
return fmt.Sprintf("You keep the %s.", v.Keepsake)
}
return ""
}