Files
gogobee/internal/plugin/dnd_expedition_milestone.go
prosolis d76c63be0c Review follow-ups A + B: armed abilities survive the fight, supply pool serialized
A. An armed ability lasted one round of a turn-based fight.

buildZoneCombatants called applyArmedAbility, which applies an ability's mods
*and* clears ArmedAbility and saves the sheet. The turn engine calls that
builder again on every !attack / !cast / !consume, so round 1 fired the ability
and disarmed the character, and every later round rebuilt them with none of its
mods. A Berserker paid stamina for a single round of BerserkerRage /
RageMeleeDmg / PhysicalResistRage / FrenzyDmgBonus. Every entry in
dndActiveAbilities had the same shape. mods.BerserkerRage was not merely unread
at close-out — by then it no longer existed.

Split arming into its two halves:

  consumeArmedAbility(c)          mutates: disarms, saves, returns the id. Once,
                                  at fight start.
  applyAbilityByID(c, id, mods)   pure: no DB write, no disarm. Safe on every
                                  rebuild. (No ability's Apply writes to the
                                  character, so this really is pure.)
  armAbilityForFight(c, mods)     consume + apply, for the auto-resolve callers
                                  that build and fight in one breath.

buildZoneCombatants now takes the already-consumed id and re-applies it. The id
rides on ActorStatuses.ArmedAbility, seeded per seat at fight start, so
partyCombatantsForSession reproduces the ability every rebuild and the close-out
can still see that a rage fired.

The close-out itself: postCombatBookkeeping now carries grantCombatAchievements
+ persistDnDPostCombatSubclass, and all four close-outs route through it —
runDungeonCombat, runZoneCombatRoster, finishCombatSession,
finishPartyCombatSession. It fires on every terminal status, not just a win: a
Berserker who rages and loses is still exhausted, which is what auto-resolve
always did.

Also: buildFightSeats and runZoneCombatRoster consumed the ability before the
checks that could sit a seat out, so a downed member was disarmed for a fight
they never joined. The refusals now run first.

B. Six unlocked read-modify-writes against the shared supply pool.

updateSupplies rewrites supplies_json wholesale, so a caller folding its delta
onto an *Expedition it read earlier discards whatever landed in between.
Handlers run one goroutine per event, so those writers genuinely interleave.

All six now go through withExpeditionSupplies, which takes advExpeditionLock,
re-reads the row, hands the closure the fresh copy and persists what it returns:
nightRolloverBurn (forage + burn in one write), grantTwoWeeksCache,
advanceToNextRegion's transit burn, campPitch, pitchAutopilotCamp, and the
ambient pack-rat drain. expeditionCmdAccept's hand-rolled lock folds onto the
same helper. expedition_sim.go is left alone: single-threaded, takes no locks.

Known consequence, for the balance track: trySimAutoArm used to live inside the
rebuild, so a simulated Fighter (second_wind) or Cleric (healing_word) re-armed
and re-spent a resource every round of every elite/boss fight. expedition-sim
drives those through the turn engine, so every prior expedition-sim corpus
overstates those two classes. Re-baseline after this, not before.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 08:01:51 -07:00

334 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}