mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
N7/B3: the Omen — one rotating world modifier per ISO week
activeOmen() is a pure function of the UTC ISO (year, week): omenTable indexed by (year*53+week)%len, so it advances weekly with no schema, no ticker state, no persistence. Five non-combat seams read it — harvest yield, supply freebie, expedition start mood, arena payout (scales gross earnings before the pot tax), and ingredient drop chance. TwinBee reveals the active omen in the existing morning DM (no net-new scheduled message). Launch set is buffs-with-texture on non-combat levers only: Bountiful Harvest, Quartermaster's Blessing, Golden Purse, Overflowing Satchels, Still Waters. Nothing touches SimulateCombat or the turn engine — the omen is keyed on the real clock, so a combat mutator would make the golden and the balance corpus week-dependent. The plan's "elites +2 ATK" is deliberately dropped for that reason. The balance sim drives the real expedition loop and would otherwise traverse all five seams, making corpus sweeps depend on the wall-clock week. NewSimRunner sets simOmenDisabled (mirrors simAutoArmEnabled), so activeOmen returns a no-effect omen under the sim. Still Waters subtracts from the daily threat *rise* only, floored at hold-steady — it never forces active decay. Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
This commit is contained in:
@@ -531,6 +531,12 @@ func (p *AdventurePlugin) arenaCompleteSession(userID id.UserID, run *ArenaRun,
|
|||||||
}
|
}
|
||||||
totalXP := int(float64(run.XPAccumulated) * xpMult)
|
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.
|
// Arena tax: 10% of earnings to community pot.
|
||||||
arenaTax := int64(math.Round(float64(run.Earnings) * 0.1))
|
arenaTax := int64(math.Round(float64(run.Earnings) * 0.1))
|
||||||
arenaNet := run.Earnings - arenaTax
|
arenaNet := run.Earnings - arenaTax
|
||||||
|
|||||||
96
internal/plugin/adventure_omen.go
Normal file
96
internal/plugin/adventure_omen.go
Normal file
@@ -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 + "_"
|
||||||
|
}
|
||||||
86
internal/plugin/adventure_omen_test.go
Normal file
86
internal/plugin/adventure_omen_test.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
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
|
// Pick a morning greeting
|
||||||
greeting, _ := advPickFlavor(MorningDM, userID, "morning_dm")
|
greeting, _ := advPickFlavor(MorningDM, userID, "morning_dm")
|
||||||
displayName, _ := loadDisplayName(userID)
|
displayName, _ := loadDisplayName(userID)
|
||||||
|
|||||||
@@ -160,6 +160,9 @@ func startExpedition(userID id.UserID, zoneID ZoneID, runID string, supplies Exp
|
|||||||
if isHol, _ := isHolidayToday(); isHol {
|
if isHol, _ := isHolidayToday(); isHol {
|
||||||
startMood = 55
|
startMood = 55
|
||||||
}
|
}
|
||||||
|
if b := activeOmen().StartMoodBonus; b > 0 { // N7/B3 the Omen
|
||||||
|
startMood += b
|
||||||
|
}
|
||||||
exp := &Expedition{
|
exp := &Expedition{
|
||||||
ID: newExpeditionID(),
|
ID: newExpeditionID(),
|
||||||
UserID: string(userID),
|
UserID: string(userID),
|
||||||
|
|||||||
@@ -384,6 +384,9 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter
|
|||||||
if isHol, _ := isHolidayToday(); isHol {
|
if isHol, _ := isHolidayToday(); isHol {
|
||||||
suppliesPurchase.StandardPacks++
|
suppliesPurchase.StandardPacks++
|
||||||
}
|
}
|
||||||
|
if pk := activeOmen().SupplyFreebiePacks; pk > 0 { // N7/B3 the Omen
|
||||||
|
suppliesPurchase.StandardPacks += pk
|
||||||
|
}
|
||||||
supplies := makeSupplies(zone.Tier, suppliesPurchase)
|
supplies := makeSupplies(zone.Tier, suppliesPurchase)
|
||||||
|
|
||||||
// Debit coins; bail on debit failure (race / cap).
|
// Debit coins; bail on debit failure (race / cap).
|
||||||
|
|||||||
@@ -463,6 +463,9 @@ func grantHarvestYield(userID id.UserID, res ZoneResource, qty int) error {
|
|||||||
if isHol, _ := isHolidayToday(); isHol {
|
if isHol, _ := isHolidayToday(); isHol {
|
||||||
qty++
|
qty++
|
||||||
}
|
}
|
||||||
|
if b := activeOmen().HarvestYieldBonus; b > 0 { // N7/B3 the Omen
|
||||||
|
qty += b
|
||||||
|
}
|
||||||
tier := zoneTierFromID(res.ZoneID)
|
tier := zoneTierFromID(res.ZoneID)
|
||||||
for i := 0; i < qty; i++ {
|
for i := 0; i < qty; i++ {
|
||||||
item := AdvItem{
|
item := AdvItem{
|
||||||
|
|||||||
@@ -117,6 +117,14 @@ func applyDailyThreatDrift(e *Expedition) (int, string, error) {
|
|||||||
return 0, "", nil
|
return 0, "", nil
|
||||||
}
|
}
|
||||||
delta, reason := dailyThreatDrift(e.DMMood)
|
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 {
|
if delta == 0 {
|
||||||
return 0, reason, nil
|
return 0, reason, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -454,7 +454,11 @@ var advIngredientActivities = []AdvActivityType{
|
|||||||
// rollZoneIngredient draws one crafting ingredient from a random legacy
|
// rollZoneIngredient draws one crafting ingredient from a random legacy
|
||||||
// gathering table at the zone's tier. Returns nil on the common no-drop path.
|
// gathering table at the zone's tier. Returns nil on the common no-drop path.
|
||||||
func rollZoneIngredient(zoneTier int) *AdvItem {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
act := advIngredientActivities[rand.IntN(len(advIngredientActivities))]
|
act := advIngredientActivities[rand.IntN(len(advIngredientActivities))]
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ func NewSimRunner(dataDir string) (*SimRunner, error) {
|
|||||||
// etc. fire the way a competent prod player would set them up.
|
// etc. fire the way a competent prod player would set them up.
|
||||||
// Without this the sim under-counts class survival.
|
// Without this the sim under-counts class survival.
|
||||||
simAutoArmEnabled = true
|
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{}
|
euro := &EuroPlugin{}
|
||||||
p := &AdventurePlugin{euro: euro}
|
p := &AdventurePlugin{euro: euro}
|
||||||
return &SimRunner{P: p, Euro: euro}, nil
|
return &SimRunner{P: p, Euro: euro}, nil
|
||||||
|
|||||||
Reference in New Issue
Block a user