Adv 2.0 D&D Phase 12 E6b: Expedition milestones (§13)

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) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-08 16:43:38 -07:00
parent 157455a3fe
commit cbb2670bb8
3 changed files with 371 additions and 0 deletions

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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))
}
}