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

@@ -707,7 +707,10 @@ func (p *AdventurePlugin) handleSellCmd(ctx MessageContext, args string) error {
} else {
result = p.advSellItem(ctx.Sender, args)
}
return p.SendDM(ctx.Sender, result)
err = p.SendDM(ctx.Sender, result)
// N1/A6 — a sale at Thom's is a moment the player is present and reading.
p.maybeFireAnchoredEvent(ctx.Sender, advEventChanceSell)
return err
}
func (p *AdventurePlugin) handleInventoryCmd(ctx MessageContext) error {

View File

@@ -624,7 +624,10 @@ func (p *AdventurePlugin) arenaCompleteSession(userID id.UserID, run *ArenaRun,
text += fmt.Sprintf("\n\n🎉 **Combat Level %d!**", newLevel)
}
return p.SendDM(userID, text)
err := p.SendDM(userID, text)
// N1/A6 — cashing out is the third mid-day event anchor.
p.maybeFireAnchoredEvent(userID, advEventChanceArena)
return err
}
// arenaProcessBail handles bail payout (called from handleArenaBail or countdown).

View File

@@ -234,8 +234,13 @@ func RollConsumableDrop(activity AdvActivityType, tier int) *AdvItem {
if rand.Float64() >= 0.15 {
return nil
}
name := names[rand.IntN(len(names))]
def := consumableDefByName(name)
return consumableAdvItem(consumableDefByName(names[rand.IntN(len(names))]))
}
// consumableAdvItem builds the inventory row for a consumable def. Drop-only
// consumables carry no shop price, so their sell value falls back to a
// per-tier baseline.
func consumableAdvItem(def *ConsumableDef) *AdvItem {
if def == nil {
return nil
}
@@ -252,6 +257,23 @@ func RollConsumableDrop(activity AdvActivityType, tier int) *AdvItem {
}
}
// consumableCache draws n consumables from the dungeon drop pool at `tier`,
// used by guaranteed grants (milestone caches) rather than the drop roll.
// Tier 1 has no dungeon pool, so it yields the buyable Berry Poultice.
func consumableCache(tier, n int) []AdvItem {
names := consumableDropTable[AdvActivityDungeon][tier]
if len(names) == 0 {
names = []string{"Berry Poultice"}
}
out := make([]AdvItem, 0, n)
for range n {
if item := consumableAdvItem(consumableDefByName(names[rand.IntN(len(names))])); item != nil {
out = append(out, *item)
}
}
return out
}
// BuyableConsumables returns all consumable defs that can be purchased from the shop.
func BuyableConsumables() []ConsumableDef {
var result []ConsumableDef

View File

@@ -23,18 +23,65 @@ type advActiveEvent struct {
ExpiresAt time.Time
}
// ── In-memory schedule ───────────────────────────────────────────────────────
// When a player acts on a given day, the next ticker iteration assigns them
// a one-shot roll-minute 60180 minutes in the future. At that minute, the
// 0.5% trigger roll fires. Each player rolls at most once per UTC day.
// ── Event anchors ────────────────────────────────────────────────────────────
// N1/A6 — events used to roll at 0.5%/player/day from a deferred ticker slot,
// which put one sighting in front of a daily player roughly every 200 days.
// They now roll at moments the player is demonstrably present and reading a
// DM: the end-of-day digest, a sale at Thom's, and an arena cashout. Each
// player still sees at most one event per UTC day.
//
// The per-anchor chances below put a player who hits all three at ~1 event
// per week; see TestAnchoredEventWeeklyRate.
const (
advEventChanceDigest = 0.08
advEventChanceSell = 0.05
advEventChanceArena = 0.05
)
var (
advEventScheduleMu sync.Mutex
advEventSchedule map[string]int // userID -> minute-of-day for the deferred roll
advEventRolled map[string]bool // userID -> already rolled today
advEventScheduleDay string // "2006-01-02" the maps belong to
advEventFiredMu sync.Mutex
advEventFired map[string]bool // userID -> an event already fired today
advEventFiredDay string // "2006-01-02" the map belongs to
)
// claimDailyEventSlot reserves today's single event slot for `userID`.
// Returns false when the player already had one fire today.
func claimDailyEventSlot(userID id.UserID, day string) bool {
advEventFiredMu.Lock()
defer advEventFiredMu.Unlock()
if advEventFiredDay != day {
advEventFired = make(map[string]bool)
advEventFiredDay = day
}
if advEventFired[string(userID)] {
return false
}
advEventFired[string(userID)] = true
return true
}
// releaseDailyEventSlot hands the slot back when the trigger bailed before
// anything reached the player, so a later anchor the same day can still fire.
func releaseDailyEventSlot(userID id.UserID) {
advEventFiredMu.Lock()
defer advEventFiredMu.Unlock()
delete(advEventFired, string(userID))
}
// maybeFireAnchoredEvent rolls `chance` at one of the A6 anchor moments and,
// on a hit, triggers the player's one mid-day event for today.
func (p *AdventurePlugin) maybeFireAnchoredEvent(userID id.UserID, chance float64) {
if rand.Float64() >= chance {
return
}
if !claimDailyEventSlot(userID, time.Now().UTC().Format("2006-01-02")) {
return
}
if !p.tryTriggerEvent(userID) {
releaseDailyEventSlot(userID)
}
}
// ── Event Ticker ─────────────────────────────────────────────────────────────
func (p *AdventurePlugin) eventTicker() {
@@ -46,90 +93,36 @@ func (p *AdventurePlugin) eventTicker() {
case <-p.stopCh:
return
case <-ticker.C:
now := time.Now().UTC()
dateKey := now.Format("2006-01-02")
currentMinute := now.Hour()*60 + now.Minute()
// Expire stale pending events every tick
expireAdvPendingEvents()
// Auto-play any combat sessions past their 1h timeout.
p.reapExpiredCombatSessions()
advEventScheduleMu.Lock()
if advEventScheduleDay != dateKey {
advEventSchedule = make(map[string]int)
advEventRolled = make(map[string]bool)
advEventScheduleDay = dateKey
}
// Schedule deferred rolls for any newly-acted players
chars, err := loadAllAdvCharacters()
if err != nil {
slog.Error("adventure: events: failed to load chars", "err", err)
advEventScheduleMu.Unlock()
continue
}
for _, c := range chars {
uid := string(c.UserID)
if !c.Alive || advEventRolled[uid] {
continue
}
if _, scheduled := advEventSchedule[uid]; scheduled {
continue
}
if !c.HasActedToday() {
continue
}
// Assign a one-shot roll 60180 minutes from now, capped to 23:50 UTC.
rollMinute := currentMinute + 60 + rand.IntN(121)
if rollMinute > 23*60+50 {
rollMinute = 23*60 + 50
}
advEventSchedule[uid] = rollMinute
slog.Info("adventure: event roll scheduled", "user", uid, "minute", rollMinute)
}
// Find players whose roll-minute has arrived
var toRoll []id.UserID
for uid, minute := range advEventSchedule {
if minute <= currentMinute && !advEventRolled[uid] {
toRoll = append(toRoll, id.UserID(uid))
advEventRolled[uid] = true
}
}
advEventScheduleMu.Unlock()
for _, uid := range toRoll {
p.tryTriggerEvent(uid)
}
}
}
}
func (p *AdventurePlugin) tryTriggerEvent(userID id.UserID) {
// Load character — must be alive and have acted today
// tryTriggerEvent fires the player's mid-day event. Reports whether an event
// actually reached them — a false return means the caller's daily slot was
// never spent. The trigger roll itself lives in maybeFireAnchoredEvent.
func (p *AdventurePlugin) tryTriggerEvent(userID id.UserID) bool {
// Load character — must be alive.
char, err := loadAdvCharacter(userID)
if err != nil || char == nil || !char.Alive || !char.HasActedToday() {
return
if err != nil || char == nil || !char.Alive {
return false
}
// Already has an active event?
active, _ := loadAdvActiveEvent(userID)
if active != nil {
return
return false
}
// Mid-fight: a turn-based session locks the run. Don't drop a random
// overworld event into a live fight — the player can't act on it without
// finishing the fight first, and the trigger DM talks over the combat feed.
if hasActiveCombatSession(userID) {
return
}
// 0.5% chance
if rand.Float64() >= 0.005 {
return
return false
}
// Determine today's activity for filtering
@@ -138,7 +131,7 @@ func (p *AdventurePlugin) tryTriggerEvent(userID id.UserID) {
// Pick an event
event := advPickRandomEvent(userID, activityType)
if event == nil {
return
return false
}
// Insert into DB
@@ -147,7 +140,7 @@ func (p *AdventurePlugin) tryTriggerEvent(userID id.UserID) {
eventID, err := insertAdvEvent(userID, event.Key, expiresAt)
if err != nil {
slog.Error("adventure: events: failed to insert event", "user", userID, "err", err)
return
return false
}
slog.Info("adventure: mid-day event triggered", "user", userID, "event", event.Key, "id", eventID)
@@ -170,6 +163,7 @@ func (p *AdventurePlugin) tryTriggerEvent(userID id.UserID) {
})
_ = p.SendMessage(id.RoomID(gr), roomLine)
}
return true
}
// handleEventRespond processes `!adventure respond`.

View File

@@ -0,0 +1,83 @@
package plugin
import (
"math/rand/v2"
"testing"
"maunium.net/go/mautrix/id"
)
// TestAnchoredEventWeeklyRate pins the A6 rebalance: a player who reads an
// end-of-day digest, sells at Thom's, and cashes out of the arena every day
// should see roughly one mid-day event per week. The old 0.5%/day roll put
// that at one per ~200 days.
//
// The simulation drives its own seeded RNG rather than the package-global
// one, so it measures the policy — the chance constants and the one-event-
// per-day cap — and never flakes on RNG ordering elsewhere in the suite.
func TestAnchoredEventWeeklyRate(t *testing.T) {
anchors := []float64{advEventChanceDigest, advEventChanceSell, advEventChanceArena}
const weeks = 20000
rng := rand.New(rand.NewPCG(0x5EED, 0xA6))
events := 0
for range weeks {
for range 7 {
firedToday := false
for _, chance := range anchors {
if firedToday {
break // one event per player per UTC day
}
if rng.Float64() < chance {
firedToday = true
}
}
if firedToday {
events++
}
}
}
perWeek := float64(events) / weeks
if perWeek < 0.8 || perWeek > 1.5 {
t.Errorf("anchored events = %.3f/week, want ~1 (0.81.5)", perWeek)
}
}
// TestClaimDailyEventSlot_OnePerDay guards the cap the rate test assumes.
func TestClaimDailyEventSlot_OnePerDay(t *testing.T) {
uid := id.UserID("@events-slot:example")
const day = "2026-07-09"
advEventFiredMu.Lock()
advEventFired = nil
advEventFiredDay = ""
advEventFiredMu.Unlock()
if !claimDailyEventSlot(uid, day) {
t.Fatal("first claim of the day should succeed")
}
if claimDailyEventSlot(uid, day) {
t.Error("second claim on the same day should be refused")
}
// A trigger that bailed before reaching the player hands the slot back.
releaseDailyEventSlot(uid)
if !claimDailyEventSlot(uid, day) {
t.Error("claim after release should succeed")
}
// A new UTC day resets everyone.
if !claimDailyEventSlot(uid, "2026-07-10") {
t.Error("claim on a new day should succeed")
}
}
// TestClaimDailyEventSlot_PerUser — one player's event doesn't consume another's.
func TestClaimDailyEventSlot_PerUser(t *testing.T) {
const day = "2026-07-11"
if !claimDailyEventSlot(id.UserID("@events-a:example"), day) {
t.Fatal("player A should claim")
}
if !claimDailyEventSlot(id.UserID("@events-b:example"), day) {
t.Error("player B should claim independently of A")
}
}

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

View File

@@ -1,6 +1,7 @@
package plugin
import (
"strings"
"testing"
"maunium.net/go/mautrix/id"
@@ -159,3 +160,163 @@ func TestAwardCompletionMilestones_NotCalledOnNonComplete(t *testing.T) {
t.Errorf("abandoned should award nothing, got %d", len(lines))
}
}
// ── N1/A4 — milestone grants ────────────────────────────────────────────────
func TestCheckDailyMilestones_TwoWeeksGrantsSupplyCache(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-milestone-twoweeks-cache:example")
defer cleanupExpeditions(uid)
supplies := ExpeditionSupplies{Current: 5, Max: 30, DailyBurn: 2, HarshMod: 1}
exp, err := startExpedition(uid, ZoneManorBlackspire, "", supplies)
if err != nil {
t.Fatal(err)
}
exp.CurrentDay = 15
p := &AdventurePlugin{}
if lines := p.checkDailyMilestones(exp); len(lines) == 0 {
t.Fatal("expected milestones on day 15")
}
if !HasMilestone(exp, MilestoneKeyTwoWeeks) {
t.Fatal("two_weeks not recorded")
}
// 5 + 3 days × 2 SU/day = 11, well under the 30 cap.
if got := exp.Supplies.Current; got != 11 {
t.Errorf("supplies after restock = %v, want 11", got)
}
stored, err := getExpedition(exp.ID)
if err != nil {
t.Fatal(err)
}
if got := stored.Supplies.Current; got != 11 {
t.Errorf("persisted supplies = %v, want 11", got)
}
inv, err := loadAdvInventory(uid)
if err != nil {
t.Fatal(err)
}
var consumables int
for _, it := range inv {
if it.Type == "consumable" {
consumables++
}
}
if consumables != twoWeeksCacheSize {
t.Errorf("cache granted %d consumables, want %d", consumables, twoWeeksCacheSize)
}
}
func TestGrantTwoWeeksCache_RestockNeverExceedsMax(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-milestone-twoweeks-cap:example")
defer cleanupExpeditions(uid)
supplies := ExpeditionSupplies{Current: 29, Max: 30, DailyBurn: 2, HarshMod: 1}
exp, err := startExpedition(uid, ZoneManorBlackspire, "", supplies)
if err != nil {
t.Fatal(err)
}
p := &AdventurePlugin{}
p.grantTwoWeeksCache(exp)
if got := exp.Supplies.Current; got != 30 {
t.Errorf("supplies = %v, want capped at 30", got)
}
}
func TestAwardCompletionMilestones_LongGameGrantsLegendary(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-milestone-longgame:example")
defer cleanupExpeditions(uid)
supplies := ExpeditionSupplies{Current: 30, Max: 30, DailyBurn: 1, HarshMod: 1}
exp, err := startExpedition(uid, ZoneDragonsLair, "", supplies)
if err != nil {
t.Fatal(err)
}
exp.Status = ExpeditionStatusComplete
p := &AdventurePlugin{}
p.AwardCompletionMilestones(exp, false)
if !HasMilestone(exp, MilestoneKeyLongGame) {
t.Fatal("long_game not recorded")
}
inv, err := loadAdvInventory(uid)
if err != nil {
t.Fatal(err)
}
var found bool
for _, it := range inv {
if strings.HasPrefix(it.SkillSource, "magic_item:") {
found = true
}
}
if !found {
t.Errorf("no magic item granted; inventory = %+v", inv)
}
}
func TestAwardCompletionMilestones_SurvivalistSetsTitle(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-milestone-survivalist:example")
defer cleanupExpeditions(uid)
supplies := ExpeditionSupplies{Current: 30, Max: 30, DailyBurn: 1, HarshMod: 1}
exp, err := startExpedition(uid, ZoneManorBlackspire, "", supplies)
if err != nil {
t.Fatal(err)
}
exp.Status = ExpeditionStatusComplete
p := &AdventurePlugin{}
p.AwardCompletionMilestones(exp, false)
if !HasMilestone(exp, MilestoneKeySurvivalist) {
t.Fatal("survivalist not recorded")
}
char, err := loadAdvCharacter(uid)
if err != nil {
t.Fatal(err)
}
if char.Title != survivalistTitle {
t.Errorf("title = %q, want %q", char.Title, survivalistTitle)
}
}
func TestAwardCompletionMilestones_SurvivalistSkippedOnForcedExtract(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-milestone-survivalist-forced:example")
defer cleanupExpeditions(uid)
supplies := ExpeditionSupplies{Current: 30, Max: 30, DailyBurn: 1, HarshMod: 1}
exp, err := startExpedition(uid, ZoneManorBlackspire, "", supplies)
if err != nil {
t.Fatal(err)
}
exp.Status = ExpeditionStatusComplete
p := &AdventurePlugin{}
p.AwardCompletionMilestones(exp, true)
if HasMilestone(exp, MilestoneKeySurvivalist) {
t.Error("survivalist awarded despite a forced extraction")
}
char, _ := loadAdvCharacter(uid)
if char != nil && char.Title == survivalistTitle {
t.Error("title set despite a forced extraction")
}
}
func TestConsumableCache_Tier1FallsBackToPoultice(t *testing.T) {
items := consumableCache(1, 3)
if len(items) != 3 {
t.Fatalf("got %d items, want 3", len(items))
}
for _, it := range items {
if it.Name != "Berry Poultice" {
t.Errorf("tier-1 cache yielded %q, want Berry Poultice", it.Name)
}
}
}

View File

@@ -298,6 +298,10 @@ func (p *AdventurePlugin) tryAutoRun(e *Expedition, now time.Time) error {
if err := p.SendDM(uid, body); err != nil {
slog.Warn("expedition: autorun DM", "user", uid, "err", err)
}
// N1/A6 — the end-of-day digest is the primary mid-day event anchor.
if campDecision.Night {
p.maybeFireAnchoredEvent(uid, advEventChanceDigest)
}
}
// Emergence seam: a run-complete reached by the background ticker is