mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 08:52:42 +00:00
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:
@@ -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)
|
line := pickMorningBriefing(e.CurrentDay)
|
||||||
body := renderMorningBriefing(e, line, burn)
|
body := renderMorningBriefing(e, line, burn)
|
||||||
if restSummary != "" {
|
if restSummary != "" {
|
||||||
@@ -221,6 +227,9 @@ func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error {
|
|||||||
for _, tl := range temporalLines {
|
for _, tl := range temporalLines {
|
||||||
body += "\n🌀 " + tl + "\n"
|
body += "\n🌀 " + tl + "\n"
|
||||||
}
|
}
|
||||||
|
for _, ml := range milestoneLines {
|
||||||
|
body += "\n" + ml
|
||||||
|
}
|
||||||
|
|
||||||
if uid := id.UserID(e.UserID); uid != "" {
|
if uid := id.UserID(e.UserID); uid != "" {
|
||||||
if err := p.SendDM(uid, body); err != nil {
|
if err := p.SendDM(uid, body); err != nil {
|
||||||
|
|||||||
201
internal/plugin/dnd_expedition_milestone.go
Normal file
201
internal/plugin/dnd_expedition_milestone.go
Normal 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
|
||||||
|
}
|
||||||
161
internal/plugin/dnd_expedition_milestone_test.go
Normal file
161
internal/plugin/dnd_expedition_milestone_test.go
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user