From b5493a0e79d6d1e35516f839b7e2bf0771eb0f62 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:49:19 -0700 Subject: [PATCH] N1/A4+A6: wire the stubbed milestone rewards, re-anchor mid-day events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A4 โ€” the three "deferred to hookup" milestone grants now pay out: Long Game (T5 clear) guaranteed Legendary via pickMagicItemForRarity -> dropMagicItemLoot, rendered in a new milestoneAward.Extra block. Survivalist (clean T3+) writes AdventureCharacter.Title and announces to the games room. No schema bump โ€” player_meta.title already exists and saveAdvCharacter persists it. Two Weeks (day 15) restocks 3 days of rations (clamped to Supplies.Max) and grants 3 zone-tier consumables. Two Weeks was specced as +5 max HP "via the expedition row". Dropped: combat MaxHP comes from stats.HPBonus, built in combat_stats.go from gear/arena/ housing with no expedition in scope. Threading one through would leak an expedition-only buff into the sim's balance corpus. A supply cache self-expires with the run and needs no combat math. A6 โ€” mid-day events rolled 0.5%/player/day from a deferred ticker slot: one sighting per ~200 days. The roll and its roll-minute scheduler are gone. Events now fire where the player is demonstrably present and reading a DM: the end-of-day digest (8%), a sale at Thom's (5%), and an arena cashout (5%), capped at one event per player per UTC day. A player who hits all three lands at ~1.19/week. tryTriggerEvent returns bool so a bail (dead / mid-fight / event already active) hands the day's slot back rather than burning it. The frequency test drives its own seeded PCG over the chance constants and the daily cap, so it measures the policy and can't flake on global RNG order. --- internal/plugin/adventure.go | 5 +- internal/plugin/adventure_arena.go | 5 +- internal/plugin/adventure_consumables.go | 26 ++- internal/plugin/adventure_events.go | 140 ++++++++------- internal/plugin/adventure_events_test.go | 83 +++++++++ internal/plugin/dnd_expedition_milestone.go | 149 ++++++++++++++-- .../plugin/dnd_expedition_milestone_test.go | 161 ++++++++++++++++++ internal/plugin/expedition_autorun.go | 4 + 8 files changed, 481 insertions(+), 92 deletions(-) create mode 100644 internal/plugin/adventure_events_test.go 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