N1/A4+A6: wire the stubbed milestone rewards, re-anchor mid-day events

A4 — the three "deferred to hookup" milestone grants now pay out:

  Long Game (T5 clear)   guaranteed Legendary via pickMagicItemForRarity
                         -> dropMagicItemLoot, rendered in a new
                         milestoneAward.Extra block.
  Survivalist (clean T3+) writes AdventureCharacter.Title and announces to
                         the games room. No schema bump — player_meta.title
                         already exists and saveAdvCharacter persists it.
  Two Weeks (day 15)     restocks 3 days of rations (clamped to Supplies.Max)
                         and grants 3 zone-tier consumables.

Two Weeks was specced as +5 max HP "via the expedition row". Dropped: combat
MaxHP comes from stats.HPBonus, built in combat_stats.go from gear/arena/
housing with no expedition in scope. Threading one through would leak an
expedition-only buff into the sim's balance corpus. A supply cache
self-expires with the run and needs no combat math.

A6 — mid-day events rolled 0.5%/player/day from a deferred ticker slot: one
sighting per ~200 days. The roll and its roll-minute scheduler are gone.
Events now fire where the player is demonstrably present and reading a DM:
the end-of-day digest (8%), a sale at Thom's (5%), and an arena cashout (5%),
capped at one event per player per UTC day. A player who hits all three lands
at ~1.19/week. tryTriggerEvent returns bool so a bail (dead / mid-fight /
event already active) hands the day's slot back rather than burning it.

The frequency test drives its own seeded PCG over the chance constants and
the daily cap, so it measures the policy and can't flake on global RNG order.
This commit is contained in:
prosolis
2026-07-09 18:49:19 -07:00
parent c9df282fde
commit b5493a0e79
8 changed files with 481 additions and 92 deletions

View File

@@ -2,6 +2,7 @@ package plugin
import (
"fmt"
"log/slog"
"strings"
"gogobee/internal/flavor"
@@ -14,12 +15,17 @@ import (
// 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)
// - 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 flag)
// - Long Game : status=complete && tier == 5 (legendary item — deferred)
// - 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.
@@ -86,6 +92,7 @@ type milestoneAward struct {
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 {
@@ -100,6 +107,10 @@ func (m milestoneAward) Render() string {
b.WriteString(m.Notes)
b.WriteString("_\n")
}
if m.Extra != "" {
b.WriteString(m.Extra)
b.WriteString("\n")
}
return b.String()
}
@@ -112,7 +123,7 @@ func (p *AdventurePlugin) checkDailyMilestones(e *Expedition) []string {
return nil
}
var out []string
award := func(key, title string, xp int, line, notes string) {
award := func(key, title string, xp int, line, notes string, grant func() string) {
if HasMilestone(e, key) {
return
}
@@ -122,33 +133,95 @@ func (p *AdventurePlugin) checkDailyMilestones(e *Expedition) []string {
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,
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), "")
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.")
"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),
"Permanent +1 primary stat (deferred — applied at character hookup).")
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 s := e.Supplies; s.DailyBurn > 0 {
s.Current += twoWeeksRestockDays * s.DailyBurn
if s.Max > 0 && s.Current > s.Max {
s.Current = s.Max
}
if s.Current > e.Supplies.Current {
if err := updateSupplies(e.ID, s); err != nil {
slog.Error("milestone: two weeks restock failed",
"expedition", e.ID, "err", err)
} else {
lines = append(lines, fmt.Sprintf(
"📦 Supplies restocked — %.1f of %.1f.", s.Current, s.Max))
e.Supplies = s
}
}
}
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
@@ -163,7 +236,7 @@ func (p *AdventurePlugin) AwardCompletionMilestones(e *Expedition, forcedExtract
}
zone, _ := getZone(e.ZoneID)
var out []string
award := func(key, title string, xp int, line, notes string) {
award := func(key, title string, xp int, line, notes string, grant func() string) {
if HasMilestone(e, key) {
return
}
@@ -173,10 +246,14 @@ func (p *AdventurePlugin) AwardCompletionMilestones(e *Expedition, forcedExtract
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,
Key: key, Title: title, XP: xp, Flavor: line, Notes: notes, Extra: extra,
}.Render())
}
@@ -184,7 +261,7 @@ func (p *AdventurePlugin) AwardCompletionMilestones(e *Expedition, forcedExtract
bonus := e.XPEarned / 10
award(MilestoneKeyPatientZero, "Patient Zero", bonus,
flavor.Pick(flavor.MilestonePatientZero),
"+10% XP bonus on the run.")
"+10% XP bonus on the run.", nil)
}
if int(zone.Tier) >= 3 && !forcedExtractedEver {
line := flavor.Pick(flavor.MilestoneSurvivalist)
@@ -193,12 +270,54 @@ func (p *AdventurePlugin) AwardCompletionMilestones(e *Expedition, forcedExtract
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.")
"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),
"Guaranteed legendary item deferred to loot-grant hookup.")
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)
}