Files
gogobee/internal/plugin/dnd_expedition_milestone.go
prosolis cb51c56f00 Adv 2.0 D&D Phase 12 E6c: Expedition flavor topup
Expand under-populated TwinBee expedition pools (singletons → 3, pairs →
4) for: morning briefings (Day1/3/14/21), evening recaps (boss/close-call/
quiet), all four threat clock bands, every zone temporal event narration,
supply depletion, and every milestone narration. Add MilestoneCartographer
and MilestoneSurvivalist pools (spec §13). Wire MilestoneSurvivalist into
AwardCompletionMilestones (was inline string).

All additions in TwinBee voice, no existing entries altered.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00

205 lines
6.6 KiB
Go

package plugin
import (
"fmt"
"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, +1 primary stat — deferred to char hookup)
//
// 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)
//
// 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.)
}
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")
}
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) {
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)
}
_ = appendExpeditionLog(e.ID, e.CurrentDay, "milestone",
"milestone awarded: "+title, line)
out = append(out, milestoneAward{
Key: key, Title: title, XP: xp, Flavor: line, Notes: notes,
}.Render())
}
// First Night — survived day 1, now waking on day 2.
if e.CurrentDay >= 2 {
award(MilestoneKeyFirstNight, "First Night", 50,
flavor.Pick(flavor.MilestoneFirstNight), "")
}
// 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.")
}
// 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).")
}
return out
}
// 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) {
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)
}
_ = appendExpeditionLog(e.ID, e.CurrentDay, "milestone",
"milestone awarded: "+title, line)
out = append(out, milestoneAward{
Key: key, Title: title, XP: xp, Flavor: line, Notes: notes,
}.Render())
}
if maxThreatSeen(e) <= 50 {
bonus := e.XPEarned / 10
award(MilestoneKeyPatientZero, "Patient Zero", bonus,
flavor.Pick(flavor.MilestonePatientZero),
"+10% XP bonus on the run.")
}
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 flag recorded; cosmetic item deferred to item-grant hookup.")
}
if int(zone.Tier) == 5 {
award(MilestoneKeyLongGame, "The Long Game", 0,
flavor.Pick(flavor.MilestoneTheLongGame),
"Guaranteed legendary item deferred to loot-grant hookup.")
}
return out
}