From cbb2670bb877f47199368c983eddcdacde1a08e2 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 8 May 2026 16:43:38 -0700 Subject: [PATCH] =?UTF-8?q?Adv=202.0=20D&D=20Phase=2012=20E6b:=20Expeditio?= =?UTF-8?q?n=20milestones=20(=C2=A713)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Daily milestones fire from the morning briefing once the day count crosses the threshold (CurrentDay reflects the new day post-rollover): - First Night (day 2, +50 XP) - Week One (day 8, +200 XP, Thom Krooke discount flag — hookup deferred) - Two Weeks (day 15, +500 XP, +1 primary stat — char-system hookup deferred) Completion milestones via AwardCompletionMilestones, exported for the combat-link boss-kill path to call once status flips to 'complete': - Patient Zero (max threat ≤ 50, +10% of XPEarned bonus) - Survivalist (tier ≥ 3, no forced extraction ever, title flag) - Long Game (tier 5, legendary item — loot-grant hookup deferred) Reuses MilestoneFirstNight/WeekOne/TwoWeeks/PatientZero/TheLongGame flavor pools from twinbee_expedition_flavor.go. Cartographer and Survivalist have no flavor pool (Survivalist uses an inline string, Cartographer is fully deferred since it needs combat-link search hooks). Awarded keys persist as RegionState["milestones"]. Max threat sampled daily into RegionState["max_threat_seen"] before milestone checks read it. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/plugin/dnd_expedition_cycle.go | 9 + internal/plugin/dnd_expedition_milestone.go | 201 ++++++++++++++++++ .../plugin/dnd_expedition_milestone_test.go | 161 ++++++++++++++ 3 files changed, 371 insertions(+) create mode 100644 internal/plugin/dnd_expedition_milestone.go create mode 100644 internal/plugin/dnd_expedition_milestone_test.go diff --git a/internal/plugin/dnd_expedition_cycle.go b/internal/plugin/dnd_expedition_cycle.go index 73405e6..5380dc4 100644 --- a/internal/plugin/dnd_expedition_cycle.go +++ b/internal/plugin/dnd_expedition_cycle.go @@ -213,6 +213,12 @@ func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error { } } + // E6b: sample today's threat into RegionState["max_threat_seen"] before + // any milestone check reads it; then award daily milestones reached by + // the new day count (First Night day 2, Week One day 8, Two Weeks day 15). + _ = recordMaxThreat(e) + milestoneLines := p.checkDailyMilestones(e) + line := pickMorningBriefing(e.CurrentDay) body := renderMorningBriefing(e, line, burn) if restSummary != "" { @@ -221,6 +227,9 @@ func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error { for _, tl := range temporalLines { body += "\n🌀 " + tl + "\n" } + for _, ml := range milestoneLines { + body += "\n" + ml + } if uid := id.UserID(e.UserID); uid != "" { if err := p.SendDM(uid, body); err != nil { diff --git a/internal/plugin/dnd_expedition_milestone.go b/internal/plugin/dnd_expedition_milestone.go new file mode 100644 index 0000000..9256cf6 --- /dev/null +++ b/internal/plugin/dnd_expedition_milestone.go @@ -0,0 +1,201 @@ +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 { + award(MilestoneKeySurvivalist, "Survivalist", 0, + "Survivalist title earned — completed a Tier "+fmt.Sprint(int(zone.Tier))+ + " expedition without a single forced extraction.", + "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 +} diff --git a/internal/plugin/dnd_expedition_milestone_test.go b/internal/plugin/dnd_expedition_milestone_test.go new file mode 100644 index 0000000..d7ceef6 --- /dev/null +++ b/internal/plugin/dnd_expedition_milestone_test.go @@ -0,0 +1,161 @@ +package plugin + +import ( + "testing" + + "maunium.net/go/mautrix/id" +) + +func TestRecordMaxThreat_OnlyClimbs(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@exp-maxthreat-climb:example") + defer cleanupExpeditions(uid) + supplies := ExpeditionSupplies{Current: 30, Max: 30, DailyBurn: 1, HarshMod: 1} + e, err := startExpedition(uid, ZoneGoblinWarrens, "", supplies) + if err != nil { + t.Fatal(err) + } + e.ThreatLevel = 30 + if err := recordMaxThreat(e); err != nil { + t.Fatalf("recordMaxThreat: %v", err) + } + // Persist may have failed; manually inspect in-memory state. + if got, _ := e.RegionState[regionStateMaxThreatKey].(float64); got != 30 { + t.Errorf("after first record, max = %v, want 30", got) + } + e.ThreatLevel = 20 + _ = recordMaxThreat(e) + if got, _ := e.RegionState[regionStateMaxThreatKey].(float64); got != 30 { + t.Errorf("after lower threat, max = %v, want 30 (no rewind)", got) + } + e.ThreatLevel = 55 + _ = recordMaxThreat(e) + if got, _ := e.RegionState[regionStateMaxThreatKey].(float64); got != 55 { + t.Errorf("after higher threat, max = %v, want 55", got) + } + if got := maxThreatSeen(e); got != 55 { + t.Errorf("maxThreatSeen = %d, want 55", got) + } +} + +func TestCheckDailyMilestones_FirstNight(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@exp-milestone-firstnight:example") + defer cleanupExpeditions(uid) + + supplies := ExpeditionSupplies{Current: 30, Max: 30, DailyBurn: 1, HarshMod: 1} + exp, err := startExpedition(uid, ZoneGoblinWarrens, "", supplies) + if err != nil { + t.Fatal(err) + } + exp.CurrentDay = 2 + p := &AdventurePlugin{} + + lines := p.checkDailyMilestones(exp) + if len(lines) != 1 { + t.Fatalf("expected 1 milestone awarded, got %d", len(lines)) + } + if !HasMilestone(exp, MilestoneKeyFirstNight) { + t.Error("first_night not recorded in RegionState") + } + // Re-check is idempotent. + again := p.checkDailyMilestones(exp) + if len(again) != 0 { + t.Errorf("second pass should award nothing, got %d", len(again)) + } +} + +func TestCheckDailyMilestones_WeekOneAndTwoWeeks(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@exp-milestone-weeks:example") + defer cleanupExpeditions(uid) + + supplies := ExpeditionSupplies{Current: 30, Max: 30, DailyBurn: 1, HarshMod: 1} + exp, err := startExpedition(uid, ZoneGoblinWarrens, "", supplies) + if err != nil { + t.Fatal(err) + } + exp.CurrentDay = 8 + p := &AdventurePlugin{} + lines := p.checkDailyMilestones(exp) + // Day 8 awards both First Night and Week One in one pass. + if len(lines) != 2 { + t.Fatalf("day 8 should award 2 (first_night + week_one), got %d", len(lines)) + } + if !HasMilestone(exp, MilestoneKeyWeekOne) { + t.Error("week_one not recorded") + } + + exp.CurrentDay = 15 + lines = p.checkDailyMilestones(exp) + if len(lines) != 1 { + t.Fatalf("day 15 should award 1 new (two_weeks), got %d", len(lines)) + } + if !HasMilestone(exp, MilestoneKeyTwoWeeks) { + t.Error("two_weeks not recorded") + } +} + +func TestAwardCompletionMilestones_PatientZero(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@exp-milestone-patient:example") + defer cleanupExpeditions(uid) + + supplies := ExpeditionSupplies{Current: 30, Max: 30, DailyBurn: 1, HarshMod: 1} + exp, err := startExpedition(uid, ZoneGoblinWarrens, "", supplies) + if err != nil { + t.Fatal(err) + } + exp.Status = ExpeditionStatusComplete + exp.XPEarned = 1000 + exp.ThreatLevel = 40 + _ = recordMaxThreat(exp) + + p := &AdventurePlugin{} + lines := p.AwardCompletionMilestones(exp, false) + if len(lines) == 0 { + t.Fatal("expected Patient Zero (max threat 40)") + } + if !HasMilestone(exp, MilestoneKeyPatientZero) { + t.Error("patient_zero not recorded") + } +} + +func TestAwardCompletionMilestones_PatientZero_ThresholdMissed(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@exp-milestone-patient-missed:example") + defer cleanupExpeditions(uid) + + supplies := ExpeditionSupplies{Current: 30, Max: 30, DailyBurn: 1, HarshMod: 1} + exp, err := startExpedition(uid, ZoneGoblinWarrens, "", supplies) + if err != nil { + t.Fatal(err) + } + exp.Status = ExpeditionStatusComplete + exp.ThreatLevel = 75 + _ = recordMaxThreat(exp) + + p := &AdventurePlugin{} + p.AwardCompletionMilestones(exp, false) + if HasMilestone(exp, MilestoneKeyPatientZero) { + t.Error("patient_zero awarded despite max threat 75") + } +} + +func TestAwardCompletionMilestones_NotCalledOnNonComplete(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@exp-milestone-noncomplete:example") + defer cleanupExpeditions(uid) + + supplies := ExpeditionSupplies{Current: 30, Max: 30, DailyBurn: 1, HarshMod: 1} + exp, err := startExpedition(uid, ZoneGoblinWarrens, "", supplies) + if err != nil { + t.Fatal(err) + } + exp.Status = ExpeditionStatusAbandoned + + p := &AdventurePlugin{} + if lines := p.AwardCompletionMilestones(exp, true); len(lines) != 0 { + t.Errorf("abandoned should award nothing, got %d", len(lines)) + } +}