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

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