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

@@ -1146,13 +1146,38 @@ func dailyCuriosStock() []MagicItem {
rng := rand.New(rand.NewPCG(seed, seed^0x9e3779b97f4a7c15))
rng.Shuffle(len(ids), func(i, j int) { ids[i], ids[j] = ids[j], ids[i] })
n := curiosStockSize
if n > len(ids) {
n = len(ids)
target := curiosStockSize
if target > len(ids) {
target = len(ids)
}
stock := make([]MagicItem, 0, n)
for _, id := range ids[:n] {
stock := make([]MagicItem, 0, target)
seen := make(map[string]bool)
// N7/E4 — a live season features its curated items at the front of the shelf,
// displacing the day's rotation. They are existing registry items, so no
// net-new power enters the economy for the week; only which items rotate in.
if s, ok := activeSeason(); ok {
for _, id := range s.CurioIDs {
if mi, ok := magicItemRegistry[id]; ok && !seen[id] {
stock = append(stock, mi)
seen[id] = true
}
}
if len(stock) > target {
target = len(stock)
}
}
// Fill the remaining slots from the day's deterministic rotation.
for _, id := range ids {
if len(stock) >= target {
break
}
if seen[id] {
continue
}
stock = append(stock, magicItemRegistry[id])
seen[id] = true
}
// Stable display order: rarity ascending, then name.
sort.Slice(stock, func(i, j int) bool {
@@ -1167,7 +1192,11 @@ func dailyCuriosStock() []MagicItem {
func (p *AdventurePlugin) luigiCuriosView(userID id.UserID, balance float64) string {
var sb strings.Builder
sb.WriteString("🔮 **Curios** — fresh stock daily at dawn\n")
if s, ok := activeSeason(); ok {
sb.WriteString(fmt.Sprintf("%s **Curios** — %s stock, this week only\n", s.Emoji, s.Name))
} else {
sb.WriteString("🔮 **Curios** — fresh stock daily at dawn\n")
}
sb.WriteString(fmt.Sprintf("💰 Balance: €%.0f\n\n", balance))
factor := p.shopSessionPriceFactor(userID)