mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 60–180 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 60–180 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`.
|
||||
|
||||
83
internal/plugin/adventure_events_test.go
Normal file
83
internal/plugin/adventure_events_test.go
Normal 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.8–1.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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user