diff --git a/internal/plugin/adventure_omen.go b/internal/plugin/adventure_omen.go index eb86bd6..989b987 100644 --- a/internal/plugin/adventure_omen.go +++ b/internal/plugin/adventure_omen.go @@ -84,6 +84,11 @@ func activeOmen() omen { if simOmenDisabled { return omen{Key: "none", Name: "None"} } + // N7/E4 — a live season's themed omen overrides the weekly rotation. Kept + // behind the simOmenDisabled guard above so the balance sim never sees it. + if s, ok := activeSeason(); ok { + return s.Omen + } y, w := time.Now().UTC().ISOWeek() return omenForWeek(y, w) } diff --git a/internal/plugin/adventure_render.go b/internal/plugin/adventure_render.go index 2eab54c..b19f1b8 100644 --- a/internal/plugin/adventure_render.go +++ b/internal/plugin/adventure_render.go @@ -318,6 +318,14 @@ func renderAdvMorningDM(userID id.UserID, equip map[EquipmentSlot]*AdvEquipment, sb.WriteString(omenMorningLine(activeOmen())) sb.WriteString("\n\n") + // N7/E4 — a live season adds a "what's live this week" banner under the Omen + // line (its themed omen already rode the line above). Same morning DM, no + // net-new scheduled message. + if s, ok := activeSeason(); ok { + sb.WriteString(seasonBannerLine(s)) + sb.WriteString("\n\n") + } + // Pick a morning greeting greeting, _ := advPickFlavor(MorningDM, userID, "morning_dm") displayName, _ := loadDisplayName(userID) diff --git a/internal/plugin/adventure_season.go b/internal/plugin/adventure_season.go new file mode 100644 index 0000000..931a104 --- /dev/null +++ b/internal/plugin/adventure_season.go @@ -0,0 +1,199 @@ +package plugin + +import ( + "time" +) + +// N7/E4 — Seasonal events (gogobee_engagement_plan.md §E4). +// +// A season is a 1-week skin anchored to a real holiday. Unlike isHolidayToday() +// (a single UTC day, worth a double-action) and unlike the Omen (an ISO-week +// rotation), a season spans a window around its anchor date and layers three +// things on top of the world for that week: +// +// 1. A themed world modifier — a reskinned Omen that OVERRIDES the weekly +// rotation (see activeOmen). It reuses the omen effect fields, so the same +// non-combat rule holds: a season never touches SimulateCombat or the turn +// engine. That is enforced structurally — activeSeason() honours +// simOmenDisabled exactly as activeOmen() does, so the balance sim never +// sees a season through any path, and the season Omen only reaches the sim's +// omen seams behind activeOmen's own simOmenDisabled guard. +// 2. A limited-time curio shelf at Luigi's — a curated selection of existing +// registry items (dailyCuriosStock). No new power enters the game; the shelf +// just changes which items rotate in for the week. +// 3. A themed visitor on the road — a season-gated ambient event that leaves a +// small keepsake and a coin gift (expedition_ambient.go). No combat: the +// ambient seam has never opened a fight and this does not change that. +// +// A season is a pure function of the UTC date and the anchor calendar, so it +// needs no schema, no ticker state, and no persistence — the same discipline as +// the Omen and the holiday calendar. + +// seasonHalfWidth is the number of days on each side of the anchor date that the +// season is live. 3 → a 7-day window (anchor ± 3). +const seasonHalfWidth = 3 + +// seasonVisitor is the themed ambient event a season adds to the road. It never +// resolves combat — it leaves a sellable keepsake and a small coin gift. +type seasonVisitor struct { + Weight int // relative weight in the ambient pick + FlavorPool []string // scene narration lines for the visit + Keepsake string // sellable trophy left behind + KeepsakeValue int64 // coin baseline of the keepsake + Coins int // flat coin gift +} + +// season is one holiday skin. +type season struct { + Key string // stable id (tests, logs) + Name string // player-facing, e.g. "Hallowtide" + Emoji string // banner emoji + Anchor func(year int) time.Time // the anchor date for a given year (UTC) + Blurb string // one-line "what's live this week" banner copy + Omen omen // themed override omen (non-combat fields only) + CurioIDs []string // curated registry IDs for Luigi's shelf + Visitor seasonVisitor // the road visitor +} + +// fixedAnchor builds an Anchor for a fixed month/day holiday. +func fixedAnchor(month time.Month, day int) func(int) time.Time { + return func(year int) time.Time { + return time.Date(year, month, day, 0, 0, 0, 0, time.UTC) + } +} + +// seasonTable is the set of anchor holidays that get a skin. Order is match +// order; the windows are disjoint so it never matters, but keep them disjoint. +var seasonTable = []season{ + { + Key: "hallowtide", Name: "Hallowtide", Emoji: "🎃", + Anchor: fixedAnchor(time.October, 31), + Blurb: "the veil's thin — spooky curios at Luigi's and things going bump on the road, all week", + Omen: omen{ + Key: "hallowtide", Name: "Hallowtide", + TwinBee: "The veil's gone thin for Hallowtide — reagents and oddments are spilling through from somewhere I'd rather not name. I'm grabbing them while they're here.", + ConsumableChanceMult: 2.0, + }, + CurioIDs: []string{ + "cloak_of_arachnida", "demon_armor", "goggles_of_night", + "slippers_of_spider_climbing", "staff_of_swarming_insects", + "sword_of_life_stealing", "dagger_of_venom", "cloak_of_the_bat", + }, + Visitor: seasonVisitor{ + Weight: 12, + FlavorPool: []string{ + "A gourd-headed thing shuffles out of the dark, sets a little carved lantern on your bedroll, and shuffles right back into it.", + "Something small and many-legged skitters past, drops a trinket at your feet as if in tribute, and is gone before you can look twice.", + "A cold draft carries a whisper and a gift — a carved gourd, still faintly warm, left where you'll find it come morning.", + }, + Keepsake: "Carved Gourd Lantern", KeepsakeValue: 60, Coins: 8, + }, + }, + { + Key: "midwinter", Name: "Midwinter Feast", Emoji: "❄️", + Anchor: fixedAnchor(time.December, 25), + Blurb: "gifts by every door — a free pack for anyone heading out, warm curios at Luigi's, all week", + Omen: omen{ + Key: "midwinter", Name: "Midwinter Feast", + TwinBee: "It's Midwinter, and someone's been leaving gifts by the outfitter's door. There's a free pack in it for anyone heading out — and I set off in high spirits.", + SupplyFreebiePacks: 1, + StartMoodBonus: 5, + }, + CurioIDs: []string{ + "boots_of_the_winterlands", "frost_brand", "ring_of_warmth", + "staff_of_frost", "potion_of_healing", "staff_of_healing", + }, + Visitor: seasonVisitor{ + Weight: 12, + FlavorPool: []string{ + "A bundled figure passes your camp without a word, leaves a frost-glass bauble hanging where the firelight catches it, and trudges on into the snow.", + "You wake to find your pack a little heavier — a wrapped bauble and a handful of coin, and a single set of bootprints leading away.", + "Bells, faint and far off. By the time they fade there's a bauble on your bedroll that wasn't there before.", + }, + Keepsake: "Frost-Glass Bauble", KeepsakeValue: 60, Coins: 10, + }, + }, + { + Key: "sweethearts", Name: "Sweethearts' Revel", Emoji: "💗", + Anchor: fixedAnchor(time.February, 14), + Blurb: "the arena crowd's throwing coin — fat purses, charming curios at Luigi's, all week", + Omen: omen{ + Key: "sweethearts", Name: "Sweethearts' Revel", + TwinBee: "The whole town's giddy for Sweethearts' week — the arena crowd's throwing coin like confetti. Win pretty and the purse pays twenty over the odds.", + ArenaPayoutMult: 1.20, + }, + CurioIDs: []string{ + "eyes_of_charming", "philter_of_love", "staff_of_charming", + "luck_blade", "stone_of_good_luck_luckstone", "pearl_of_power", + "glamoured_studded_leather", + }, + Visitor: seasonVisitor{ + Weight: 12, + FlavorPool: []string{ + "A courier in festival colours finds you even out here, presses a ribbon-wrapped token into your hand with a wink, and hurries back the way they came.", + "A paper heart, pinned to a token and left on your pack — 'from an admirer,' it says, and nothing else.", + "Someone's left a ribbon-wrapped keepsake by the trail marker, addressed to no one and everyone. You pocket it.", + }, + Keepsake: "Ribbon-Wrapped Token", KeepsakeValue: 55, Coins: 10, + }, + }, + { + Key: "first_bloom", Name: "First Bloom", Emoji: "🌷", + Anchor: func(year int) time.Time { return easterDate(year) }, + Blurb: "everything's growing eager — fuller harvests, green curios at Luigi's, all week", + Omen: omen{ + Key: "first_bloom", Name: "First Bloom", + TwinBee: "First Bloom's on us — everything's growing twice as eager and the woods feel calm with it. Every gather comes up fuller, and the dread's slow to rise.", + HarvestYieldBonus: 1, + ThreatDriftReduce: 1, + }, + CurioIDs: []string{ + "bag_of_beans", "potion_of_growth", "potion_of_animal_friendship", + "ring_of_animal_influence", "horn_of_valhalla", "sword_of_life_stealing", + }, + Visitor: seasonVisitor{ + Weight: 12, + FlavorPool: []string{ + "A hare the size of a hound lopes up, regards you with unsettling calm, and leaves a pressed blossom on the ground before bounding off.", + "New vines have crept over your gear in the night — and tucked among them, a perfect pressed blossom and a little coin, as if the woods were paying rent.", + "The undergrowth parts on its own, sets a spring blossom at your feet, and closes again. You decide not to question it.", + }, + Keepsake: "Pressed Spring Blossom", KeepsakeValue: 50, Coins: 8, + }, + }, +} + +// activeSeason returns the season whose window contains today (UTC), or false. +// Honours simOmenDisabled so the balance sim never observes a season through any +// path — the same neutralisation activeOmen applies to the weekly rotation. +func activeSeason() (season, bool) { + if simOmenDisabled { + return season{}, false + } + now := time.Now().UTC() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) + return seasonForDate(today) +} + +// seasonForDate is the pure core of activeSeason, split out for tests. The +// anchor is checked against the target year and its neighbours so a window that +// straddles a year boundary still resolves. +func seasonForDate(today time.Time) (season, bool) { + for _, s := range seasonTable { + for _, yr := range []int{today.Year() - 1, today.Year(), today.Year() + 1} { + a := s.Anchor(yr) + lo := a.AddDate(0, 0, -seasonHalfWidth) + hi := a.AddDate(0, 0, seasonHalfWidth) + if !today.Before(lo) && !today.After(hi) { + return s, true + } + } + } + return season{}, false +} + +// seasonBannerLine is the "what's live this week" one-liner surfaced under the +// Omen line in the morning DM. Empty when no season is active. +func seasonBannerLine(s season) string { + return s.Emoji + " **" + s.Name + "** is here — " + s.Blurb + "." +} diff --git a/internal/plugin/adventure_season_test.go b/internal/plugin/adventure_season_test.go new file mode 100644 index 0000000..9bc9d8a --- /dev/null +++ b/internal/plugin/adventure_season_test.go @@ -0,0 +1,120 @@ +package plugin + +import ( + "testing" + "time" +) + +func date(y int, m time.Month, d int) time.Time { + return time.Date(y, m, d, 0, 0, 0, 0, time.UTC) +} + +// TestSeasonForDate_ActiveOnAnchor — every season resolves on its own anchor day +// and both window edges (±seasonHalfWidth), and is dark one day past each edge. +func TestSeasonForDate_ActiveOnAnchor(t *testing.T) { + for _, s := range seasonTable { + a := s.Anchor(2026) + for _, off := range []int{-seasonHalfWidth, 0, seasonHalfWidth} { + got, ok := seasonForDate(a.AddDate(0, 0, off)) + if !ok || got.Key != s.Key { + t.Errorf("%s: day offset %d → (%v, %q), want (true, %q)", + s.Key, off, ok, got.Key, s.Key) + } + } + for _, off := range []int{-seasonHalfWidth - 1, seasonHalfWidth + 1} { + if got, ok := seasonForDate(a.AddDate(0, 0, off)); ok { + t.Errorf("%s: day offset %d should be dark, got %q", s.Key, off, got.Key) + } + } + } +} + +// TestSeasonForDate_WindowsDisjoint — no calendar day resolves to two seasons, so +// the first-match order in seasonForDate never hides overlapping content. +func TestSeasonForDate_WindowsDisjoint(t *testing.T) { + for y := 2024; y <= 2030; y++ { + day := date(y, time.January, 1) + end := date(y+1, time.January, 1) + for day.Before(end) { + matches := 0 + for _, s := range seasonTable { + for _, yr := range []int{day.Year() - 1, day.Year(), day.Year() + 1} { + a := s.Anchor(yr) + if !day.Before(a.AddDate(0, 0, -seasonHalfWidth)) && + !day.After(a.AddDate(0, 0, seasonHalfWidth)) { + matches++ + break + } + } + } + if matches > 1 { + t.Fatalf("%s resolves to %d seasons (windows must be disjoint)", + day.Format("2006-01-02"), matches) + } + day = day.AddDate(0, 0, 1) + } + } +} + +// TestSeasonTable_CuratedItemsExist — every curated curio ID is a real registry +// item, so a season's shelf never lists a phantom the buyer can't purchase. Guards +// against typos and registry edits (mirrors TestTemperMaterialsAreObtainable). +func TestSeasonTable_CuratedItemsExist(t *testing.T) { + for _, s := range seasonTable { + if len(s.CurioIDs) == 0 { + t.Errorf("%s has no curated curios", s.Key) + } + for _, id := range s.CurioIDs { + if _, ok := magicItemRegistry[id]; !ok { + t.Errorf("%s curio %q is not in magicItemRegistry", s.Key, id) + } + } + } +} + +// TestSeasonTable_OmenIsNonCombat — a season's themed omen carries an effect and +// only touches the non-combat omen levers, so overriding the weekly rotation with +// it can never move the combat golden or the balance corpus. §E4/§B3. +func TestSeasonTable_OmenIsNonCombat(t *testing.T) { + for _, s := range seasonTable { + o := s.Omen + hasEffect := o.HarvestYieldBonus > 0 || o.SupplyFreebiePacks > 0 || + o.StartMoodBonus > 0 || o.ArenaPayoutMult > 1.0 || + o.ConsumableChanceMult > 1.0 || o.ThreatDriftReduce > 0 + if !hasEffect { + t.Errorf("%s omen has no active effect", s.Key) + } + if o.Name == "" || o.TwinBee == "" { + t.Errorf("%s omen missing player-facing copy", s.Key) + } + } +} + +// TestSeasonTable_VisitorRewardComplete — every season's road visitor has flavor, +// a named keepsake, and a positive keepsake value, so applyAmbientEffect always +// has a real reward to grant. +func TestSeasonTable_VisitorRewardComplete(t *testing.T) { + for _, s := range seasonTable { + v := s.Visitor + if len(v.FlavorPool) == 0 { + t.Errorf("%s visitor has no flavor", s.Key) + } + if v.Keepsake == "" || v.KeepsakeValue <= 0 { + t.Errorf("%s visitor keepsake incomplete: %q value %d", s.Key, v.Keepsake, v.KeepsakeValue) + } + if v.Weight <= 0 { + t.Errorf("%s visitor has non-positive weight %d", s.Key, v.Weight) + } + } +} + +// TestActiveSeason_NeutralizedInSim — the balance sim must never observe a season +// through any path; activeSeason honours simOmenDisabled exactly as activeOmen does. +func TestActiveSeason_NeutralizedInSim(t *testing.T) { + prev := simOmenDisabled + simOmenDisabled = true + defer func() { simOmenDisabled = prev }() + if s, ok := activeSeason(); ok { + t.Fatalf("activeSeason returned %q while simOmenDisabled", s.Key) + } +} diff --git a/internal/plugin/adventure_shop.go b/internal/plugin/adventure_shop.go index 8969057..fdf0d1a 100644 --- a/internal/plugin/adventure_shop.go +++ b/internal/plugin/adventure_shop.go @@ -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) diff --git a/internal/plugin/expedition_ambient.go b/internal/plugin/expedition_ambient.go index 2101921..bc9b19f 100644 --- a/internal/plugin/expedition_ambient.go +++ b/internal/plugin/expedition_ambient.go @@ -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 "" }