diff --git a/internal/plugin/adventure_arena.go b/internal/plugin/adventure_arena.go index 7d20046..50b31ea 100644 --- a/internal/plugin/adventure_arena.go +++ b/internal/plugin/adventure_arena.go @@ -531,6 +531,12 @@ func (p *AdventurePlugin) arenaCompleteSession(userID id.UserID, run *ArenaRun, } totalXP := int(float64(run.XPAccumulated) * xpMult) + // N7/B3 the Omen — a payout-boosting week scales gross earnings before the + // pot tax, so both the player's cut and the pot's rake grow proportionally. + if m := activeOmen().ArenaPayoutMult; m > 1.0 { + run.Earnings = int64(float64(run.Earnings) * m) + } + // Arena tax: 10% of earnings to community pot. arenaTax := int64(math.Round(float64(run.Earnings) * 0.1)) arenaNet := run.Earnings - arenaTax diff --git a/internal/plugin/adventure_omen.go b/internal/plugin/adventure_omen.go new file mode 100644 index 0000000..eb86bd6 --- /dev/null +++ b/internal/plugin/adventure_omen.go @@ -0,0 +1,96 @@ +package plugin + +import ( + "time" +) + +// N7/B3 — the Omen: one rotating world modifier per ISO week +// (gogobee_engagement_plan.md §B3). +// +// The active omen is a pure function of the ISO (year, week): omenTable indexed +// by (year*53 + week) % len, so it advances by exactly one entry each week and +// needs no schema, no ticker state, and no persistence. Every seam reads +// activeOmen() the same way isHolidayToday() is read, and a zero-valued effect +// field means "this omen doesn't touch that seam." +// +// Launch-set rule (§B3): every omen is a buff-with-texture on a NON-combat +// lever — harvest, supplies, expedition mood/threat, arena payout, ingredient +// drops. Nothing touches SimulateCombat or the turn engine: the omen is keyed +// on the real clock, so a combat-affecting omen would make the combat golden +// and the balance corpus week-dependent. The plan's "elites +2 ATK" mutator is +// deliberately omitted for exactly that reason. + +type omen struct { + Key string // stable id (tests, logs) + Name string // player-facing name, e.g. "Bountiful Harvest" + TwinBee string // first-person announce blurb (TwinBee voice) + + // Effect fields — zero == no effect at that seam. + HarvestYieldBonus int // + units per harvest grant + SupplyFreebiePacks int // + complimentary standard packs at outfitting + StartMoodBonus int // + starting DM mood on a new expedition + ArenaPayoutMult float64 // >1 scales arena net earnings + ConsumableChanceMult float64 // >1 scales the per-win ingredient drop chance + ThreatDriftReduce int // subtract from the daily threat *rise* (floored at hold-steady) +} + +// simOmenDisabled neutralizes the weekly Omen for the balance sim, so a corpus +// sweep's results never depend on which wall-clock week it was run in. Set true +// by NewSimRunner (mirrors simAutoArmEnabled). Every seam reads activeOmen(), +// which returns the no-effect omen while this is set. +var simOmenDisabled bool + +// omenTable is the weekly rotation. Keep it a set of distinct non-combat levers; +// order is the rotation order. Adding an entry reshuffles the schedule but never +// breaks determinism (still a pure function of the week). +var omenTable = []omen{ + { + Key: "bountiful_harvest", Name: "Bountiful Harvest", + TwinBee: "The land's feeling generous this week — every gather I make comes up with a little extra in hand.", + HarvestYieldBonus: 1, + }, + { + Key: "quartermasters_blessing", Name: "Quartermaster's Blessing", + TwinBee: "Somebody left the storerooms unlocked. Outfitting an expedition this week? There's a free pack in it, and I'm setting out in good spirits.", + SupplyFreebiePacks: 1, + StartMoodBonus: 5, + }, + { + Key: "golden_purse", Name: "Golden Purse", + TwinBee: "The arena crowd's flush this week — purses are paying out fat. Twenty percent over the odds, if you can win it.", + ArenaPayoutMult: 1.20, + }, + { + Key: "overflowing_satchels", Name: "Overflowing Satchels", + TwinBee: "Reagents are turning up everywhere I look — twice as often as usual. Good week to stock the crafting shelf.", + ConsumableChanceMult: 2.0, + }, + { + Key: "still_waters", Name: "Still Waters", + TwinBee: "It's quiet out there. Whatever's hunting us is slow to rouse this week — the daily dread holds steady instead of creeping up.", + ThreatDriftReduce: 1, + }, +} + +// omenForWeek returns the omen for an ISO (year, week). Pure and total. +func omenForWeek(year, week int) omen { + idx := ((year*53)+week)%len(omenTable) + len(omenTable) + return omenTable[idx%len(omenTable)] +} + +// activeOmen returns this week's omen (UTC ISO week), or a no-effect omen when +// the balance sim has disabled it. +func activeOmen() omen { + if simOmenDisabled { + return omen{Key: "none", Name: "None"} + } + y, w := time.Now().UTC().ISOWeek() + return omenForWeek(y, w) +} + +// omenMorningLine is the compact one-liner surfaced in the morning DM (the §B3 +// announce seam). Empty is never returned — an omen is always active — but the +// caller may still choose when to show it. +func omenMorningLine(o omen) string { + return "🔮 **The Omen — " + o.Name + ".** _" + o.TwinBee + "_" +} diff --git a/internal/plugin/adventure_omen_test.go b/internal/plugin/adventure_omen_test.go new file mode 100644 index 0000000..e88da41 --- /dev/null +++ b/internal/plugin/adventure_omen_test.go @@ -0,0 +1,86 @@ +package plugin + +import "testing" + +// TestOmenForWeek_Deterministic — the same ISO week always yields the same omen. +func TestOmenForWeek_Deterministic(t *testing.T) { + a := omenForWeek(2026, 28) + b := omenForWeek(2026, 28) + if a.Key != b.Key { + t.Fatalf("omenForWeek not deterministic: %q vs %q", a.Key, b.Key) + } +} + +// TestOmenForWeek_AdvancesEachWeek — consecutive weeks step by exactly one table +// entry, so the schedule rotates rather than sticking or skipping. +func TestOmenForWeek_AdvancesEachWeek(t *testing.T) { + n := len(omenTable) + for w := 1; w <= n; w++ { + cur := omenForWeek(2026, w) + next := omenForWeek(2026, w+1) + wantNext := omenTable[((2026*53)+w+1)%n] + if next.Key != wantNext.Key { + t.Errorf("week %d→%d: got %q, want %q", w, w+1, next.Key, wantNext.Key) + } + if cur.Key == next.Key { + t.Errorf("week %d and %d produced the same omen %q (should advance)", w, w+1, cur.Key) + } + } +} + +// TestOmenForWeek_TotalOverYearBoundary — never panics across week/year edges, +// including ISO week 53. +func TestOmenForWeek_TotalOverYearBoundary(t *testing.T) { + for y := 2020; y <= 2030; y++ { + for w := 1; w <= 53; w++ { + o := omenForWeek(y, w) + if o.Key == "" { + t.Fatalf("omenForWeek(%d,%d) returned zero omen", y, w) + } + } + } +} + +// TestOmenTable_NonCombatOnly — every omen carries at least one effect and the +// struct has no combat lever, so the launch set can never move the golden or the +// balance corpus. §B3. +func TestOmenTable_NonCombatOnly(t *testing.T) { + for _, o := range omenTable { + 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("omen %q has no active effect", o.Key) + } + if o.Name == "" || o.TwinBee == "" { + t.Errorf("omen %q missing player-facing copy", o.Key) + } + } +} + +// TestActiveOmen_SimDisabled — the balance sim neutralizes the omen so corpus +// results are wall-clock-independent. §B3. +func TestActiveOmen_SimDisabled(t *testing.T) { + defer func() { simOmenDisabled = false }() + simOmenDisabled = true + o := activeOmen() + if o.Key != "none" { + t.Errorf("sim-disabled omen = %q, want none", o.Key) + } + if o.HarvestYieldBonus != 0 || o.SupplyFreebiePacks != 0 || o.StartMoodBonus != 0 || + o.ArenaPayoutMult != 0 || o.ConsumableChanceMult != 0 || o.ThreatDriftReduce != 0 { + t.Errorf("sim-disabled omen must have no effect, got %+v", o) + } +} + +// TestOmenKeysUnique — no duplicate keys (a dup would make the rotation land on +// the same omen two of every len(omenTable) weeks). +func TestOmenKeysUnique(t *testing.T) { + seen := map[string]bool{} + for _, o := range omenTable { + if seen[o.Key] { + t.Errorf("duplicate omen key %q", o.Key) + } + seen[o.Key] = true + } +} diff --git a/internal/plugin/adventure_render.go b/internal/plugin/adventure_render.go index ed1d11c..2eab54c 100644 --- a/internal/plugin/adventure_render.go +++ b/internal/plugin/adventure_render.go @@ -312,6 +312,12 @@ func renderAdvMorningDM(userID id.UserID, equip map[EquipmentSlot]*AdvEquipment, sb.WriteString(fmt.Sprintf("🎉 Happy %s! In recognition of %s, today's adventures come with TwinBee's blessing — new zone & expedition runs start at +5 mood, expedition outfitting includes a complimentary standard pack, and every harvest yields one extra unit.\n\n", holidayName, holidayName)) } + // N7/B3 the Omen — this week's world modifier, in TwinBee's voice. Rides the + // existing morning DM (no net-new scheduled message); persistent one-liner so + // a mid-week arrival still learns the active omen. + sb.WriteString(omenMorningLine(activeOmen())) + sb.WriteString("\n\n") + // Pick a morning greeting greeting, _ := advPickFlavor(MorningDM, userID, "morning_dm") displayName, _ := loadDisplayName(userID) diff --git a/internal/plugin/dnd_expedition.go b/internal/plugin/dnd_expedition.go index 374bb0f..ca927ab 100644 --- a/internal/plugin/dnd_expedition.go +++ b/internal/plugin/dnd_expedition.go @@ -160,6 +160,9 @@ func startExpedition(userID id.UserID, zoneID ZoneID, runID string, supplies Exp if isHol, _ := isHolidayToday(); isHol { startMood = 55 } + if b := activeOmen().StartMoodBonus; b > 0 { // N7/B3 the Omen + startMood += b + } exp := &Expedition{ ID: newExpeditionID(), UserID: string(userID), diff --git a/internal/plugin/dnd_expedition_cmd.go b/internal/plugin/dnd_expedition_cmd.go index 201b788..ee68271 100644 --- a/internal/plugin/dnd_expedition_cmd.go +++ b/internal/plugin/dnd_expedition_cmd.go @@ -384,6 +384,9 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter if isHol, _ := isHolidayToday(); isHol { suppliesPurchase.StandardPacks++ } + if pk := activeOmen().SupplyFreebiePacks; pk > 0 { // N7/B3 the Omen + suppliesPurchase.StandardPacks += pk + } supplies := makeSupplies(zone.Tier, suppliesPurchase) // Debit coins; bail on debit failure (race / cap). diff --git a/internal/plugin/dnd_expedition_harvest.go b/internal/plugin/dnd_expedition_harvest.go index 74f5df1..d322b16 100644 --- a/internal/plugin/dnd_expedition_harvest.go +++ b/internal/plugin/dnd_expedition_harvest.go @@ -463,6 +463,9 @@ func grantHarvestYield(userID id.UserID, res ZoneResource, qty int) error { if isHol, _ := isHolidayToday(); isHol { qty++ } + if b := activeOmen().HarvestYieldBonus; b > 0 { // N7/B3 the Omen + qty += b + } tier := zoneTierFromID(res.ZoneID) for i := 0; i < qty; i++ { item := AdvItem{ diff --git a/internal/plugin/dnd_expedition_threat.go b/internal/plugin/dnd_expedition_threat.go index 3572973..c54c5ec 100644 --- a/internal/plugin/dnd_expedition_threat.go +++ b/internal/plugin/dnd_expedition_threat.go @@ -117,6 +117,14 @@ func applyDailyThreatDrift(e *Expedition) (int, string, error) { return 0, "", nil } delta, reason := dailyThreatDrift(e.DMMood) + // N7/B3 the Omen — a "still waters" week subtracts from the daily threat + // *rise* only (guarded on delta > 0), floored so the worst case is threat + // holding steady for the day; it never turns a rise into active decay. + if r := activeOmen().ThreatDriftReduce; r > 0 && delta > 0 { + if delta -= r; delta < 0 { + delta = 0 + } + } if delta == 0 { return 0, reason, nil } diff --git a/internal/plugin/dnd_zone_loot.go b/internal/plugin/dnd_zone_loot.go index 80dd66b..25181d6 100644 --- a/internal/plugin/dnd_zone_loot.go +++ b/internal/plugin/dnd_zone_loot.go @@ -454,7 +454,11 @@ var advIngredientActivities = []AdvActivityType{ // rollZoneIngredient draws one crafting ingredient from a random legacy // gathering table at the zone's tier. Returns nil on the common no-drop path. func rollZoneIngredient(zoneTier int) *AdvItem { - if rand.Float64() >= advIngredientDropChance { + chance := advIngredientDropChance + if m := activeOmen().ConsumableChanceMult; m > 1.0 { // N7/B3 the Omen + chance *= m + } + if rand.Float64() >= chance { return nil } act := advIngredientActivities[rand.IntN(len(advIngredientActivities))] diff --git a/internal/plugin/expedition_sim.go b/internal/plugin/expedition_sim.go index f5d6a68..6bb96db 100644 --- a/internal/plugin/expedition_sim.go +++ b/internal/plugin/expedition_sim.go @@ -49,6 +49,10 @@ func NewSimRunner(dataDir string) (*SimRunner, error) { // etc. fire the way a competent prod player would set them up. // Without this the sim under-counts class survival. simAutoArmEnabled = true + // N7/B3 — neutralize the weekly Omen so corpus results don't depend on which + // wall-clock week the sweep runs in (the sim's synthetic clock never reaches + // activeOmen's time.Now()). + simOmenDisabled = true euro := &EuroPlugin{} p := &AdventurePlugin{euro: euro} return &SimRunner{P: p, Euro: euro}, nil