package plugin import ( "fmt" "log/slog" "strings" "gogobee/internal/flavor" "maunium.net/go/mautrix/id" ) // Phase 12 E6b — Expedition milestones (§13). // // Daily milestones fire from the morning briefing once their day-condition // 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, 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) // - 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. // // Awarded keys persist as RegionState["milestones"] = []string. Max threat // observed persists as RegionState["max_threat_seen"] = float64 (sqlite JSON). const ( MilestoneKeyFirstNight = "first_night" MilestoneKeyWeekOne = "week_one" MilestoneKeyTwoWeeks = "two_weeks" MilestoneKeyPatientZero = "patient_zero" MilestoneKeySurvivalist = "survivalist" MilestoneKeyLongGame = "long_game" MilestoneKeyCartographer = "cartographer" regionStateMilestonesKey = "milestones" regionStateMaxThreatKey = "max_threat_seen" ) // HasMilestone reports whether `key` has already been awarded for `e`. func HasMilestone(e *Expedition, key string) bool { return regionListContains(regionListFromState(e, regionStateMilestonesKey), key) } // awardMilestone is idempotent — returns false if already awarded. func awardMilestone(e *Expedition, key string) (bool, error) { return addRegionListEntry(e, regionStateMilestonesKey, key) } // recordMaxThreat updates RegionState["max_threat_seen"] if the current // threat level is higher than the stored value. Persists. func recordMaxThreat(e *Expedition) error { if e == nil { return nil } if e.RegionState == nil { e.RegionState = map[string]any{} } prev, _ := e.RegionState[regionStateMaxThreatKey].(float64) cur := float64(e.ThreatLevel) if cur <= prev { return nil } e.RegionState[regionStateMaxThreatKey] = cur return persistRegionState(e) } // maxThreatSeen returns the highest threat ever observed during the // expedition (post-rollover, daily samples). func maxThreatSeen(e *Expedition) int { if e == nil || e.RegionState == nil { return 0 } v, _ := e.RegionState[regionStateMaxThreatKey].(float64) return int(v) } // milestoneAward bundles what gets returned to the briefing/recap renderer // so it can splice the line into its DM body. type milestoneAward struct { Key string Title string 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 { var b strings.Builder b.WriteString(fmt.Sprintf("🏆 **Milestone — %s** (+%d XP)\n", m.Title, m.XP)) if m.Flavor != "" { b.WriteString(m.Flavor) b.WriteString("\n") } if m.Notes != "" { b.WriteString("_") b.WriteString(m.Notes) b.WriteString("_\n") } if m.Extra != "" { b.WriteString(m.Extra) b.WriteString("\n") } return b.String() } // checkDailyMilestones inspects the expedition after a day rollover and // awards any newly-reached daily milestones. Awarded milestones are // persisted, XP is granted via p.xp, and the rendered lines are returned // for the briefing renderer to append. func (p *AdventurePlugin) checkDailyMilestones(e *Expedition) []string { if e == nil { return nil } var out []string award := func(key, title string, xp int, line, notes string, grant func() string) { if HasMilestone(e, key) { return } if _, err := awardMilestone(e, key); err != nil { return } 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, 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), "", 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.", nil) } // Two Weeks — survived day 14, now waking on day 15. if e.CurrentDay >= 15 { award(MilestoneKeyTwoWeeks, "Two Weeks", 500, 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 e.Supplies.DailyBurn > 0 { var restocked bool pooled, err := p.withExpeditionSupplies(e.ID, func(fresh *Expedition) (ExpeditionSupplies, error) { s := fresh.Supplies if s.DailyBurn <= 0 { return s, nil } s.Current += twoWeeksRestockDays * s.DailyBurn if s.Max > 0 && s.Current > s.Max { s.Current = s.Max } restocked = s.Current > fresh.Supplies.Current return s, nil }) switch { case err != nil: slog.Error("milestone: two weeks restock failed", "expedition", e.ID, "err", err) case restocked: lines = append(lines, fmt.Sprintf( "📦 Supplies restocked — %.1f of %.1f.", pooled.Current, pooled.Max)) e.Supplies = pooled default: e.Supplies = pooled } } 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 // rendered lines so the boss-kill DM can include them. // // `forcedExtractedEver` is whether any forced extraction occurred at any // point (caller knows: status==abandoned in expedition history, or a // forced-extract flag in RegionState the caller maintains). func (p *AdventurePlugin) AwardCompletionMilestones(e *Expedition, forcedExtractedEver bool) []string { if e == nil || e.Status != ExpeditionStatusComplete { return nil } zone, _ := getZone(e.ZoneID) var out []string award := func(key, title string, xp int, line, notes string, grant func() string) { if HasMilestone(e, key) { return } if _, err := awardMilestone(e, key); err != nil { return } 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, Extra: extra, }.Render()) } if maxThreatSeen(e) <= 50 { bonus := e.XPEarned / 10 award(MilestoneKeyPatientZero, "Patient Zero", bonus, flavor.Pick(flavor.MilestonePatientZero), "+10% XP bonus on the run.", nil) } if int(zone.Tier) >= 3 && !forcedExtractedEver { line := flavor.Pick(flavor.MilestoneSurvivalist) if line == "" { line = "Survivalist title earned — completed a Tier " + fmt.Sprint(int(zone.Tier)) + " expedition without a single forced extraction." } award(MilestoneKeySurvivalist, "Survivalist", 0, line, "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), "", 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) }