diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index 8041d5e..ef39778 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -707,7 +707,10 @@ func (p *AdventurePlugin) handleSellCmd(ctx MessageContext, args string) error { } else { result = p.advSellItem(ctx.Sender, args) } - return p.SendDM(ctx.Sender, result) + err = p.SendDM(ctx.Sender, result) + // N1/A6 โ€” a sale at Thom's is a moment the player is present and reading. + p.maybeFireAnchoredEvent(ctx.Sender, advEventChanceSell) + return err } func (p *AdventurePlugin) handleInventoryCmd(ctx MessageContext) error { diff --git a/internal/plugin/adventure_arena.go b/internal/plugin/adventure_arena.go index 485cef4..f84a056 100644 --- a/internal/plugin/adventure_arena.go +++ b/internal/plugin/adventure_arena.go @@ -624,7 +624,10 @@ func (p *AdventurePlugin) arenaCompleteSession(userID id.UserID, run *ArenaRun, text += fmt.Sprintf("\n\n๐ŸŽ‰ **Combat Level %d!**", newLevel) } - return p.SendDM(userID, text) + err := p.SendDM(userID, text) + // N1/A6 โ€” cashing out is the third mid-day event anchor. + p.maybeFireAnchoredEvent(userID, advEventChanceArena) + return err } // arenaProcessBail handles bail payout (called from handleArenaBail or countdown). diff --git a/internal/plugin/adventure_consumables.go b/internal/plugin/adventure_consumables.go index 7fffe48..02a2a8c 100644 --- a/internal/plugin/adventure_consumables.go +++ b/internal/plugin/adventure_consumables.go @@ -234,8 +234,13 @@ func RollConsumableDrop(activity AdvActivityType, tier int) *AdvItem { if rand.Float64() >= 0.15 { return nil } - name := names[rand.IntN(len(names))] - def := consumableDefByName(name) + return consumableAdvItem(consumableDefByName(names[rand.IntN(len(names))])) +} + +// consumableAdvItem builds the inventory row for a consumable def. Drop-only +// consumables carry no shop price, so their sell value falls back to a +// per-tier baseline. +func consumableAdvItem(def *ConsumableDef) *AdvItem { if def == nil { return nil } @@ -252,6 +257,23 @@ func RollConsumableDrop(activity AdvActivityType, tier int) *AdvItem { } } +// consumableCache draws n consumables from the dungeon drop pool at `tier`, +// used by guaranteed grants (milestone caches) rather than the drop roll. +// Tier 1 has no dungeon pool, so it yields the buyable Berry Poultice. +func consumableCache(tier, n int) []AdvItem { + names := consumableDropTable[AdvActivityDungeon][tier] + if len(names) == 0 { + names = []string{"Berry Poultice"} + } + out := make([]AdvItem, 0, n) + for range n { + if item := consumableAdvItem(consumableDefByName(names[rand.IntN(len(names))])); item != nil { + out = append(out, *item) + } + } + return out +} + // BuyableConsumables returns all consumable defs that can be purchased from the shop. func BuyableConsumables() []ConsumableDef { var result []ConsumableDef diff --git a/internal/plugin/adventure_events.go b/internal/plugin/adventure_events.go index c525121..8a4b19a 100644 --- a/internal/plugin/adventure_events.go +++ b/internal/plugin/adventure_events.go @@ -23,18 +23,65 @@ type advActiveEvent struct { ExpiresAt time.Time } -// โ”€โ”€ In-memory schedule โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -// When a player acts on a given day, the next ticker iteration assigns them -// a one-shot roll-minute 60โ€“180 minutes in the future. At that minute, the -// 0.5% trigger roll fires. Each player rolls at most once per UTC day. +// โ”€โ”€ Event anchors โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// N1/A6 โ€” events used to roll at 0.5%/player/day from a deferred ticker slot, +// which put one sighting in front of a daily player roughly every 200 days. +// They now roll at moments the player is demonstrably present and reading a +// DM: the end-of-day digest, a sale at Thom's, and an arena cashout. Each +// player still sees at most one event per UTC day. +// +// The per-anchor chances below put a player who hits all three at ~1 event +// per week; see TestAnchoredEventWeeklyRate. +const ( + advEventChanceDigest = 0.08 + advEventChanceSell = 0.05 + advEventChanceArena = 0.05 +) var ( - advEventScheduleMu sync.Mutex - advEventSchedule map[string]int // userID -> minute-of-day for the deferred roll - advEventRolled map[string]bool // userID -> already rolled today - advEventScheduleDay string // "2006-01-02" the maps belong to + advEventFiredMu sync.Mutex + advEventFired map[string]bool // userID -> an event already fired today + advEventFiredDay string // "2006-01-02" the map belongs to ) +// claimDailyEventSlot reserves today's single event slot for `userID`. +// Returns false when the player already had one fire today. +func claimDailyEventSlot(userID id.UserID, day string) bool { + advEventFiredMu.Lock() + defer advEventFiredMu.Unlock() + if advEventFiredDay != day { + advEventFired = make(map[string]bool) + advEventFiredDay = day + } + if advEventFired[string(userID)] { + return false + } + advEventFired[string(userID)] = true + return true +} + +// releaseDailyEventSlot hands the slot back when the trigger bailed before +// anything reached the player, so a later anchor the same day can still fire. +func releaseDailyEventSlot(userID id.UserID) { + advEventFiredMu.Lock() + defer advEventFiredMu.Unlock() + delete(advEventFired, string(userID)) +} + +// maybeFireAnchoredEvent rolls `chance` at one of the A6 anchor moments and, +// on a hit, triggers the player's one mid-day event for today. +func (p *AdventurePlugin) maybeFireAnchoredEvent(userID id.UserID, chance float64) { + if rand.Float64() >= chance { + return + } + if !claimDailyEventSlot(userID, time.Now().UTC().Format("2006-01-02")) { + return + } + if !p.tryTriggerEvent(userID) { + releaseDailyEventSlot(userID) + } +} + // โ”€โ”€ Event Ticker โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ func (p *AdventurePlugin) eventTicker() { @@ -46,90 +93,36 @@ func (p *AdventurePlugin) eventTicker() { case <-p.stopCh: return case <-ticker.C: - now := time.Now().UTC() - dateKey := now.Format("2006-01-02") - currentMinute := now.Hour()*60 + now.Minute() - // Expire stale pending events every tick expireAdvPendingEvents() // Auto-play any combat sessions past their 1h timeout. p.reapExpiredCombatSessions() - - advEventScheduleMu.Lock() - if advEventScheduleDay != dateKey { - advEventSchedule = make(map[string]int) - advEventRolled = make(map[string]bool) - advEventScheduleDay = dateKey - } - - // Schedule deferred rolls for any newly-acted players - chars, err := loadAllAdvCharacters() - if err != nil { - slog.Error("adventure: events: failed to load chars", "err", err) - advEventScheduleMu.Unlock() - continue - } - for _, c := range chars { - uid := string(c.UserID) - if !c.Alive || advEventRolled[uid] { - continue - } - if _, scheduled := advEventSchedule[uid]; scheduled { - continue - } - if !c.HasActedToday() { - continue - } - // Assign a one-shot roll 60โ€“180 minutes from now, capped to 23:50 UTC. - rollMinute := currentMinute + 60 + rand.IntN(121) - if rollMinute > 23*60+50 { - rollMinute = 23*60 + 50 - } - advEventSchedule[uid] = rollMinute - slog.Info("adventure: event roll scheduled", "user", uid, "minute", rollMinute) - } - - // Find players whose roll-minute has arrived - var toRoll []id.UserID - for uid, minute := range advEventSchedule { - if minute <= currentMinute && !advEventRolled[uid] { - toRoll = append(toRoll, id.UserID(uid)) - advEventRolled[uid] = true - } - } - advEventScheduleMu.Unlock() - - for _, uid := range toRoll { - p.tryTriggerEvent(uid) - } } } } -func (p *AdventurePlugin) tryTriggerEvent(userID id.UserID) { - // Load character โ€” must be alive and have acted today +// tryTriggerEvent fires the player's mid-day event. Reports whether an event +// actually reached them โ€” a false return means the caller's daily slot was +// never spent. The trigger roll itself lives in maybeFireAnchoredEvent. +func (p *AdventurePlugin) tryTriggerEvent(userID id.UserID) bool { + // Load character โ€” must be alive. char, err := loadAdvCharacter(userID) - if err != nil || char == nil || !char.Alive || !char.HasActedToday() { - return + if err != nil || char == nil || !char.Alive { + return false } // Already has an active event? active, _ := loadAdvActiveEvent(userID) if active != nil { - return + return false } // Mid-fight: a turn-based session locks the run. Don't drop a random // overworld event into a live fight โ€” the player can't act on it without // finishing the fight first, and the trigger DM talks over the combat feed. if hasActiveCombatSession(userID) { - return - } - - // 0.5% chance - if rand.Float64() >= 0.005 { - return + return false } // Determine today's activity for filtering @@ -138,7 +131,7 @@ func (p *AdventurePlugin) tryTriggerEvent(userID id.UserID) { // Pick an event event := advPickRandomEvent(userID, activityType) if event == nil { - return + return false } // Insert into DB @@ -147,7 +140,7 @@ func (p *AdventurePlugin) tryTriggerEvent(userID id.UserID) { eventID, err := insertAdvEvent(userID, event.Key, expiresAt) if err != nil { slog.Error("adventure: events: failed to insert event", "user", userID, "err", err) - return + return false } slog.Info("adventure: mid-day event triggered", "user", userID, "event", event.Key, "id", eventID) @@ -170,6 +163,7 @@ func (p *AdventurePlugin) tryTriggerEvent(userID id.UserID) { }) _ = p.SendMessage(id.RoomID(gr), roomLine) } + return true } // handleEventRespond processes `!adventure respond`. diff --git a/internal/plugin/adventure_events_test.go b/internal/plugin/adventure_events_test.go new file mode 100644 index 0000000..e65d5b8 --- /dev/null +++ b/internal/plugin/adventure_events_test.go @@ -0,0 +1,83 @@ +package plugin + +import ( + "math/rand/v2" + "testing" + + "maunium.net/go/mautrix/id" +) + +// TestAnchoredEventWeeklyRate pins the A6 rebalance: a player who reads an +// end-of-day digest, sells at Thom's, and cashes out of the arena every day +// should see roughly one mid-day event per week. The old 0.5%/day roll put +// that at one per ~200 days. +// +// The simulation drives its own seeded RNG rather than the package-global +// one, so it measures the policy โ€” the chance constants and the one-event- +// per-day cap โ€” and never flakes on RNG ordering elsewhere in the suite. +func TestAnchoredEventWeeklyRate(t *testing.T) { + anchors := []float64{advEventChanceDigest, advEventChanceSell, advEventChanceArena} + + const weeks = 20000 + rng := rand.New(rand.NewPCG(0x5EED, 0xA6)) + events := 0 + for range weeks { + for range 7 { + firedToday := false + for _, chance := range anchors { + if firedToday { + break // one event per player per UTC day + } + if rng.Float64() < chance { + firedToday = true + } + } + if firedToday { + events++ + } + } + } + + perWeek := float64(events) / weeks + if perWeek < 0.8 || perWeek > 1.5 { + t.Errorf("anchored events = %.3f/week, want ~1 (0.8โ€“1.5)", perWeek) + } +} + +// TestClaimDailyEventSlot_OnePerDay guards the cap the rate test assumes. +func TestClaimDailyEventSlot_OnePerDay(t *testing.T) { + uid := id.UserID("@events-slot:example") + const day = "2026-07-09" + + advEventFiredMu.Lock() + advEventFired = nil + advEventFiredDay = "" + advEventFiredMu.Unlock() + + if !claimDailyEventSlot(uid, day) { + t.Fatal("first claim of the day should succeed") + } + if claimDailyEventSlot(uid, day) { + t.Error("second claim on the same day should be refused") + } + // A trigger that bailed before reaching the player hands the slot back. + releaseDailyEventSlot(uid) + if !claimDailyEventSlot(uid, day) { + t.Error("claim after release should succeed") + } + // A new UTC day resets everyone. + if !claimDailyEventSlot(uid, "2026-07-10") { + t.Error("claim on a new day should succeed") + } +} + +// TestClaimDailyEventSlot_PerUser โ€” one player's event doesn't consume another's. +func TestClaimDailyEventSlot_PerUser(t *testing.T) { + const day = "2026-07-11" + if !claimDailyEventSlot(id.UserID("@events-a:example"), day) { + t.Fatal("player A should claim") + } + if !claimDailyEventSlot(id.UserID("@events-b:example"), day) { + t.Error("player B should claim independently of A") + } +} diff --git a/internal/plugin/dnd_expedition_milestone.go b/internal/plugin/dnd_expedition_milestone.go index 3582e7e..2b20bcc 100644 --- a/internal/plugin/dnd_expedition_milestone.go +++ b/internal/plugin/dnd_expedition_milestone.go @@ -2,6 +2,7 @@ package plugin import ( "fmt" + "log/slog" "strings" "gogobee/internal/flavor" @@ -14,12 +15,17 @@ import ( // is met (e.CurrentDay reflects the new day after rollover): // - First Night : day 2 (+50 XP) // - Week One : day 8 (+200 XP, Thom Krooke discount flag) -// - Two Weeks : day 15 (+500 XP, +1 primary stat โ€” deferred to char hookup) +// - Two Weeks : day 15 (+500 XP, supply restock + consumable cache) // // Completion milestones fire from the boss-kill path (combat-link hook): // - Patient Zero : status=complete && max_threat_seen โ‰ค 50 (+10% of XPEarned) -// - Survivalist : status=complete && tier โ‰ฅ 3 && never forced-extracted (title flag) -// - Long Game : status=complete && tier == 5 (legendary item โ€” deferred) +// - Survivalist : status=complete && tier โ‰ฅ 3 && never forced-extracted (title) +// - Long Game : status=complete && tier == 5 (guaranteed legendary item) +// +// Two Weeks pays out in supplies rather than a stat bump: combat MaxHP is +// built from gear/arena/housing in combat_stats.go with no expedition in +// scope, and threading one through would leak an expedition-only buff into +// the sim's balance corpus. A cache self-expires when the run ends. // // Cartographer (search every room) is fully deferred โ€” needs combat-link // search-hook data not yet plumbed. @@ -86,6 +92,7 @@ type milestoneAward struct { XP int Flavor string Notes string // optional extra ("Thom Krooke discount flag set", etc.) + Extra string // grant narration (loot lines, cache contents) } func (m milestoneAward) Render() string { @@ -100,6 +107,10 @@ func (m milestoneAward) Render() string { b.WriteString(m.Notes) b.WriteString("_\n") } + if m.Extra != "" { + b.WriteString(m.Extra) + b.WriteString("\n") + } return b.String() } @@ -112,7 +123,7 @@ func (p *AdventurePlugin) checkDailyMilestones(e *Expedition) []string { return nil } var out []string - award := func(key, title string, xp int, line, notes string) { + award := func(key, title string, xp int, line, notes string, grant func() string) { if HasMilestone(e, key) { return } @@ -122,33 +133,95 @@ func (p *AdventurePlugin) checkDailyMilestones(e *Expedition) []string { if xp > 0 && p.xp != nil { p.xp.GrantXP(id.UserID(e.UserID), xp, "expedition milestone: "+title) } + var extra string + if grant != nil { + extra = grant() + } _ = appendExpeditionLog(e.ID, e.CurrentDay, "milestone", "milestone awarded: "+title, line) out = append(out, milestoneAward{ - Key: key, Title: title, XP: xp, Flavor: line, Notes: notes, + Key: key, Title: title, XP: xp, Flavor: line, Notes: notes, Extra: extra, }.Render()) } // First Night โ€” survived day 1, now waking on day 2. if e.CurrentDay >= 2 { award(MilestoneKeyFirstNight, "First Night", 50, - flavor.Pick(flavor.MilestoneFirstNight), "") + flavor.Pick(flavor.MilestoneFirstNight), "", nil) } // Week One โ€” survived day 7, now waking on day 8. if e.CurrentDay >= 8 { award(MilestoneKeyWeekOne, "Week One", 200, flavor.Pick(flavor.MilestoneWeekOne), - "Thom Krooke discount flag set on next visit.") + "Thom Krooke discount flag set on next visit.", nil) } // Two Weeks โ€” survived day 14, now waking on day 15. if e.CurrentDay >= 15 { award(MilestoneKeyTwoWeeks, "Two Weeks", 500, - flavor.Pick(flavor.MilestoneTwoWeeks), - "Permanent +1 primary stat (deferred โ€” applied at character hookup).") + flavor.Pick(flavor.MilestoneTwoWeeks), "", + func() string { return p.grantTwoWeeksCache(e) }) } return out } +// twoWeeksRestockDays โ€” days of rations the Two Weeks cache puts back in the +// pack. Never overfills past what the purchased packs can hold. +const twoWeeksRestockDays = 3 + +// twoWeeksCacheSize โ€” consumables granted alongside the restock. +const twoWeeksCacheSize = 3 + +// grantTwoWeeksCache restocks supplies and drops a small consumable cache at +// the zone's tier. Returns the narration block for the briefing DM. +func (p *AdventurePlugin) grantTwoWeeksCache(e *Expedition) string { + var lines []string + + if s := e.Supplies; s.DailyBurn > 0 { + s.Current += twoWeeksRestockDays * s.DailyBurn + if s.Max > 0 && s.Current > s.Max { + s.Current = s.Max + } + if s.Current > e.Supplies.Current { + if err := updateSupplies(e.ID, s); err != nil { + slog.Error("milestone: two weeks restock failed", + "expedition", e.ID, "err", err) + } else { + lines = append(lines, fmt.Sprintf( + "๐Ÿ“ฆ Supplies restocked โ€” %.1f of %.1f.", s.Current, s.Max)) + e.Supplies = s + } + } + } + + zone, _ := getZone(e.ZoneID) + userID := id.UserID(e.UserID) + counts := map[string]int{} + var order []string + for _, item := range consumableCache(int(zone.Tier), twoWeeksCacheSize) { + if err := addAdvInventoryItem(userID, item); err != nil { + slog.Error("milestone: two weeks cache grant failed", + "user", userID, "item", item.Name, "err", err) + continue + } + if counts[item.Name] == 0 { + order = append(order, item.Name) + } + counts[item.Name]++ + } + if len(order) > 0 { + var parts []string + for _, name := range order { + if n := counts[name]; n > 1 { + parts = append(parts, fmt.Sprintf("%s ร—%d", name, n)) + } else { + parts = append(parts, name) + } + } + lines = append(lines, "๐Ÿงช "+strings.Join(parts, ", ")) + } + return strings.Join(lines, "\n") +} + // AwardCompletionMilestones is called by the combat-link boss-kill path // (deferred) once status flips to 'complete'. It checks Patient Zero, // Survivalist, and Long Game and grants the relevant XP. Returns the @@ -163,7 +236,7 @@ func (p *AdventurePlugin) AwardCompletionMilestones(e *Expedition, forcedExtract } zone, _ := getZone(e.ZoneID) var out []string - award := func(key, title string, xp int, line, notes string) { + award := func(key, title string, xp int, line, notes string, grant func() string) { if HasMilestone(e, key) { return } @@ -173,10 +246,14 @@ func (p *AdventurePlugin) AwardCompletionMilestones(e *Expedition, forcedExtract if xp > 0 && p.xp != nil { p.xp.GrantXP(id.UserID(e.UserID), xp, "expedition milestone: "+title) } + var extra string + if grant != nil { + extra = grant() + } _ = appendExpeditionLog(e.ID, e.CurrentDay, "milestone", "milestone awarded: "+title, line) out = append(out, milestoneAward{ - Key: key, Title: title, XP: xp, Flavor: line, Notes: notes, + Key: key, Title: title, XP: xp, Flavor: line, Notes: notes, Extra: extra, }.Render()) } @@ -184,7 +261,7 @@ func (p *AdventurePlugin) AwardCompletionMilestones(e *Expedition, forcedExtract bonus := e.XPEarned / 10 award(MilestoneKeyPatientZero, "Patient Zero", bonus, flavor.Pick(flavor.MilestonePatientZero), - "+10% XP bonus on the run.") + "+10% XP bonus on the run.", nil) } if int(zone.Tier) >= 3 && !forcedExtractedEver { line := flavor.Pick(flavor.MilestoneSurvivalist) @@ -193,12 +270,54 @@ func (p *AdventurePlugin) AwardCompletionMilestones(e *Expedition, forcedExtract fmt.Sprint(int(zone.Tier)) + " expedition without a single forced extraction." } award(MilestoneKeySurvivalist, "Survivalist", 0, line, - "Title flag recorded; cosmetic item deferred to item-grant hookup.") + "Title set โ€” shows on your sheet.", + func() string { return p.grantSurvivalistTitle(e, zone) }) } if int(zone.Tier) == 5 { award(MilestoneKeyLongGame, "The Long Game", 0, - flavor.Pick(flavor.MilestoneTheLongGame), - "Guaranteed legendary item deferred to loot-grant hookup.") + flavor.Pick(flavor.MilestoneTheLongGame), "", + func() string { return p.grantLongGameLegendary(e) }) } return out } + +// survivalistTitle โ€” the title written to player_meta.title on a clean T3+ clear. +const survivalistTitle = "Survivalist" + +// grantSurvivalistTitle writes the title to the character and posts the +// games-room notice. Returns "" โ€” the milestone's own Notes line already +// tells the player what happened. +func (p *AdventurePlugin) grantSurvivalistTitle(e *Expedition, zone ZoneDefinition) string { + userID := id.UserID(e.UserID) + char, err := loadAdvCharacter(userID) + if err != nil || char == nil { + slog.Error("milestone: survivalist title load failed", "user", userID, "err", err) + return "" + } + if char.Title != survivalistTitle { + char.Title = survivalistTitle + if err := saveAdvCharacter(char); err != nil { + slog.Error("milestone: survivalist title save failed", "user", userID, "err", err) + return "" + } + } + gr := gamesRoom() + if gr == "" { + return "" + } + displayName, _ := loadDisplayName(userID) + p.SendMessage(id.RoomID(gr), fmt.Sprintf( + "๐ŸŽ–๏ธ **%s** walked out of %s on their own two feet โ€” Tier %d, no extraction. **Survivalist.**", + displayName, zone.Display, int(zone.Tier))) + return "" +} + +// grantLongGameLegendary hands out the guaranteed Legendary for a T5 clear. +func (p *AdventurePlugin) grantLongGameLegendary(e *Expedition) string { + mi, ok := pickMagicItemForRarity(RarityLegendary, nil) + if !ok { + slog.Error("milestone: no legendary in registry", "expedition", e.ID) + return "" + } + return p.dropMagicItemLoot(id.UserID(e.UserID), mi, LootTierLegendary) +} diff --git a/internal/plugin/dnd_expedition_milestone_test.go b/internal/plugin/dnd_expedition_milestone_test.go index d7ceef6..a069abe 100644 --- a/internal/plugin/dnd_expedition_milestone_test.go +++ b/internal/plugin/dnd_expedition_milestone_test.go @@ -1,6 +1,7 @@ package plugin import ( + "strings" "testing" "maunium.net/go/mautrix/id" @@ -159,3 +160,163 @@ func TestAwardCompletionMilestones_NotCalledOnNonComplete(t *testing.T) { t.Errorf("abandoned should award nothing, got %d", len(lines)) } } + +// โ”€โ”€ N1/A4 โ€” milestone grants โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +func TestCheckDailyMilestones_TwoWeeksGrantsSupplyCache(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@exp-milestone-twoweeks-cache:example") + defer cleanupExpeditions(uid) + + supplies := ExpeditionSupplies{Current: 5, Max: 30, DailyBurn: 2, HarshMod: 1} + exp, err := startExpedition(uid, ZoneManorBlackspire, "", supplies) + if err != nil { + t.Fatal(err) + } + exp.CurrentDay = 15 + + p := &AdventurePlugin{} + if lines := p.checkDailyMilestones(exp); len(lines) == 0 { + t.Fatal("expected milestones on day 15") + } + if !HasMilestone(exp, MilestoneKeyTwoWeeks) { + t.Fatal("two_weeks not recorded") + } + + // 5 + 3 days ร— 2 SU/day = 11, well under the 30 cap. + if got := exp.Supplies.Current; got != 11 { + t.Errorf("supplies after restock = %v, want 11", got) + } + stored, err := getExpedition(exp.ID) + if err != nil { + t.Fatal(err) + } + if got := stored.Supplies.Current; got != 11 { + t.Errorf("persisted supplies = %v, want 11", got) + } + + inv, err := loadAdvInventory(uid) + if err != nil { + t.Fatal(err) + } + var consumables int + for _, it := range inv { + if it.Type == "consumable" { + consumables++ + } + } + if consumables != twoWeeksCacheSize { + t.Errorf("cache granted %d consumables, want %d", consumables, twoWeeksCacheSize) + } +} + +func TestGrantTwoWeeksCache_RestockNeverExceedsMax(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@exp-milestone-twoweeks-cap:example") + defer cleanupExpeditions(uid) + + supplies := ExpeditionSupplies{Current: 29, Max: 30, DailyBurn: 2, HarshMod: 1} + exp, err := startExpedition(uid, ZoneManorBlackspire, "", supplies) + if err != nil { + t.Fatal(err) + } + p := &AdventurePlugin{} + p.grantTwoWeeksCache(exp) + if got := exp.Supplies.Current; got != 30 { + t.Errorf("supplies = %v, want capped at 30", got) + } +} + +func TestAwardCompletionMilestones_LongGameGrantsLegendary(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@exp-milestone-longgame:example") + defer cleanupExpeditions(uid) + + supplies := ExpeditionSupplies{Current: 30, Max: 30, DailyBurn: 1, HarshMod: 1} + exp, err := startExpedition(uid, ZoneDragonsLair, "", supplies) + if err != nil { + t.Fatal(err) + } + exp.Status = ExpeditionStatusComplete + + p := &AdventurePlugin{} + p.AwardCompletionMilestones(exp, false) + if !HasMilestone(exp, MilestoneKeyLongGame) { + t.Fatal("long_game not recorded") + } + + inv, err := loadAdvInventory(uid) + if err != nil { + t.Fatal(err) + } + var found bool + for _, it := range inv { + if strings.HasPrefix(it.SkillSource, "magic_item:") { + found = true + } + } + if !found { + t.Errorf("no magic item granted; inventory = %+v", inv) + } +} + +func TestAwardCompletionMilestones_SurvivalistSetsTitle(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@exp-milestone-survivalist:example") + defer cleanupExpeditions(uid) + + supplies := ExpeditionSupplies{Current: 30, Max: 30, DailyBurn: 1, HarshMod: 1} + exp, err := startExpedition(uid, ZoneManorBlackspire, "", supplies) + if err != nil { + t.Fatal(err) + } + exp.Status = ExpeditionStatusComplete + + p := &AdventurePlugin{} + p.AwardCompletionMilestones(exp, false) + if !HasMilestone(exp, MilestoneKeySurvivalist) { + t.Fatal("survivalist not recorded") + } + char, err := loadAdvCharacter(uid) + if err != nil { + t.Fatal(err) + } + if char.Title != survivalistTitle { + t.Errorf("title = %q, want %q", char.Title, survivalistTitle) + } +} + +func TestAwardCompletionMilestones_SurvivalistSkippedOnForcedExtract(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@exp-milestone-survivalist-forced:example") + defer cleanupExpeditions(uid) + + supplies := ExpeditionSupplies{Current: 30, Max: 30, DailyBurn: 1, HarshMod: 1} + exp, err := startExpedition(uid, ZoneManorBlackspire, "", supplies) + if err != nil { + t.Fatal(err) + } + exp.Status = ExpeditionStatusComplete + + p := &AdventurePlugin{} + p.AwardCompletionMilestones(exp, true) + if HasMilestone(exp, MilestoneKeySurvivalist) { + t.Error("survivalist awarded despite a forced extraction") + } + char, _ := loadAdvCharacter(uid) + if char != nil && char.Title == survivalistTitle { + t.Error("title set despite a forced extraction") + } +} + +func TestConsumableCache_Tier1FallsBackToPoultice(t *testing.T) { + items := consumableCache(1, 3) + if len(items) != 3 { + t.Fatalf("got %d items, want 3", len(items)) + } + for _, it := range items { + if it.Name != "Berry Poultice" { + t.Errorf("tier-1 cache yielded %q, want Berry Poultice", it.Name) + } + } +} diff --git a/internal/plugin/expedition_autorun.go b/internal/plugin/expedition_autorun.go index 47f85f8..8451f10 100644 --- a/internal/plugin/expedition_autorun.go +++ b/internal/plugin/expedition_autorun.go @@ -298,6 +298,10 @@ func (p *AdventurePlugin) tryAutoRun(e *Expedition, now time.Time) error { if err := p.SendDM(uid, body); err != nil { slog.Warn("expedition: autorun DM", "user", uid, "err", err) } + // N1/A6 โ€” the end-of-day digest is the primary mid-day event anchor. + if campDecision.Night { + p.maybeFireAnchoredEvent(uid, advEventChanceDigest) + } } // Emergence seam: a run-complete reached by the background ticker is