package plugin import ( "fmt" "strings" "testing" "time" "maunium.net/go/mautrix/id" ) // ── Equipment Mastery ────────────────────────────────────────────────────── func TestAdvEquipmentMasteryBonus(t *testing.T) { cases := []struct { name string used map[EquipmentSlot]int want float64 }{ {"empty equip", nil, 0}, {"all sub-50", map[EquipmentSlot]int{SlotWeapon: 49, SlotArmor: 1}, 0}, {"one slot crossed first threshold", map[EquipmentSlot]int{SlotWeapon: 50}, 1}, {"one slot at all three thresholds", map[EquipmentSlot]int{SlotWeapon: 250}, 3}, {"mixed: 100 + 50", map[EquipmentSlot]int{SlotWeapon: 100, SlotArmor: 50}, 3}, {"all 5 slots maxed = 15 raw, capped at 10", map[EquipmentSlot]int{ SlotWeapon: 999, SlotArmor: 999, SlotHelmet: 999, SlotBoots: 999, SlotTool: 999, }, 10}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { equip := map[EquipmentSlot]*AdvEquipment{} for slot, used := range tc.used { equip[slot] = &AdvEquipment{ActionsUsed: used} } got := advEquipmentMasteryBonus(equip) if got != tc.want { t.Errorf("got %.1f, want %.1f", got, tc.want) } }) } } func TestAdvSlotRelevantToActivity(t *testing.T) { cases := []struct { slot EquipmentSlot activity AdvActivityType want bool }{ // Combat exercises the combat loadout. {SlotWeapon, AdvActivityDungeon, true}, {SlotArmor, AdvActivityDungeon, true}, {SlotHelmet, AdvActivityDungeon, true}, {SlotBoots, AdvActivityDungeon, true}, {SlotTool, AdvActivityDungeon, false}, // a dungeon shouldn't master a pickaxe // Each harvest activity exercises only the tool. {SlotTool, AdvActivityMining, true}, {SlotTool, AdvActivityForaging, true}, {SlotTool, AdvActivityFishing, true}, {SlotWeapon, AdvActivityMining, false}, // foraging shouldn't master a sword {SlotArmor, AdvActivityForaging, false}, {SlotHelmet, AdvActivityFishing, false}, {SlotBoots, AdvActivityMining, false}, } for _, tc := range cases { t.Run(fmt.Sprintf("%s_%s", tc.slot, tc.activity), func(t *testing.T) { if got := advSlotRelevantToActivity(tc.slot, tc.activity); got != tc.want { t.Errorf("got %v, want %v", got, tc.want) } }) } } func TestAdvMasteryRowSegment(t *testing.T) { cases := []struct { used int want string }{ {0, ""}, {1, "1/50"}, {49, "49/50"}, {50, "50/100 ✦"}, {99, "99/100 ✦"}, {100, "100/250 ✦✦"}, {249, "249/250 ✦✦"}, {250, "250 ✦✦✦"}, {500, "500 ✦✦✦"}, } for _, tc := range cases { t.Run(fmt.Sprintf("used=%d", tc.used), func(t *testing.T) { if got := advMasteryRowSegment(tc.used); got != tc.want { t.Errorf("got %q, want %q", got, tc.want) } }) } } func TestAdvMasteryRollup(t *testing.T) { cases := []struct { name string used map[EquipmentSlot]int wantCrossed int wantMaxed int wantBonus float64 wantAnyProgress bool }{ {"empty", nil, 0, 0, 0, false}, {"sub-50 only", map[EquipmentSlot]int{SlotWeapon: 30}, 0, 0, 0, true}, {"one slot 1 threshold", map[EquipmentSlot]int{SlotWeapon: 50}, 1, 0, 1, true}, {"one slot maxed", map[EquipmentSlot]int{SlotWeapon: 250}, 3, 1, 3, true}, {"two slots, 5 thresholds", map[EquipmentSlot]int{SlotWeapon: 100, SlotArmor: 250}, 5, 1, 5, true}, {"all maxed = 15 raw, capped at 10", map[EquipmentSlot]int{ SlotWeapon: 999, SlotArmor: 999, SlotHelmet: 999, SlotBoots: 999, SlotTool: 999, }, 15, 5, 10, true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { equip := map[EquipmentSlot]*AdvEquipment{} for slot, used := range tc.used { equip[slot] = &AdvEquipment{ActionsUsed: used} } got := advMasteryRollup(equip) if got.ThresholdsCrossed != tc.wantCrossed { t.Errorf("ThresholdsCrossed: got %d, want %d", got.ThresholdsCrossed, tc.wantCrossed) } if got.MaxedSlots != tc.wantMaxed { t.Errorf("MaxedSlots: got %d, want %d", got.MaxedSlots, tc.wantMaxed) } if got.Bonus != tc.wantBonus { t.Errorf("Bonus: got %.1f, want %.1f", got.Bonus, tc.wantBonus) } if got.AnyProgress != tc.wantAnyProgress { t.Errorf("AnyProgress: got %v, want %v", got.AnyProgress, tc.wantAnyProgress) } }) } } func TestRenderAdvMasteryAggregate(t *testing.T) { cases := []struct { name string used map[EquipmentSlot]int wantEmpty bool wantSubstring []string wantNot []string }{ {"no progress → empty", nil, true, nil, nil}, {"sub-threshold progress → building up", map[EquipmentSlot]int{SlotWeapon: 30}, false, []string{"building up", "first threshold at 50"}, []string{"+0%", "loot quality"}}, {"one threshold crossed → singular threshold", map[EquipmentSlot]int{SlotWeapon: 50}, false, []string{"+1% loot quality", "1 threshold crossed"}, []string{"thresholds", "maxed", "cap reached"}}, {"three thresholds, no maxed → plural threshold no maxed", map[EquipmentSlot]int{SlotWeapon: 100, SlotArmor: 50}, false, []string{"+3% loot quality", "3 thresholds crossed"}, []string{"maxed", "cap reached"}}, {"one maxed + extras → mention maxed slot", map[EquipmentSlot]int{SlotWeapon: 250, SlotArmor: 50}, false, []string{"+4% loot quality", "4 thresholds crossed", "1 slot maxed"}, []string{"cap reached", "slots maxed"}}, {"capped → flag cap reached", map[EquipmentSlot]int{ SlotWeapon: 250, SlotArmor: 250, SlotHelmet: 250, SlotBoots: 250, SlotTool: 250, }, false, []string{"+10% loot quality", "15 thresholds crossed", "5 slots maxed", "cap reached"}, nil}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { equip := map[EquipmentSlot]*AdvEquipment{} for slot, used := range tc.used { equip[slot] = &AdvEquipment{ActionsUsed: used} } got := renderAdvMasteryAggregate(equip) if tc.wantEmpty { if got != "" { t.Fatalf("expected empty, got %q", got) } return } if got == "" { t.Fatalf("expected non-empty, got empty") } for _, s := range tc.wantSubstring { if !strings.Contains(got, s) { t.Errorf("expected to contain %q, got %q", s, got) } } for _, s := range tc.wantNot { if strings.Contains(got, s) { t.Errorf("should not contain %q, got %q", s, got) } } }) } } // ── Treasure Rank Ordering ───────────────────────────────────────────────── func TestAdvTreasureRank_Ordering(t *testing.T) { t5 := &AdvTreasureDef{Key: "a", Tier: 5, Bonuses: []advTreasureBonusDef{{Type: "all_skills", Value: 5}}} t3multi := &AdvTreasureDef{Key: "b", Tier: 3, Bonuses: []advTreasureBonusDef{ {Type: "combat_level", Value: 1}, {Type: "death_chance", Value: -1}, }} t3single := &AdvTreasureDef{Key: "c", Tier: 3, Bonuses: []advTreasureBonusDef{{Type: "loot_quality", Value: 5}}} t3same := &AdvTreasureDef{Key: "d", Tier: 3, Bonuses: []advTreasureBonusDef{{Type: "loot_quality", Value: 5}}} if !advTreasureRankBetter(advTreasureRank(t5), advTreasureRank(t3multi)) { t.Error("T5 should beat T3 regardless of bonus count") } if !advTreasureRankBetter(advTreasureRank(t3multi), advTreasureRank(t3single)) { t.Error("equal tier: more bonuses should win") } if advTreasureRankBetter(advTreasureRank(t3single), advTreasureRank(t3same)) { t.Error("equal tier and bonus count: should NOT be strictly better (no churn)") } if advTreasureRankBetter(advTreasureRank(t3same), advTreasureRank(t3single)) { t.Error("equal tier and bonus count, reversed: also not strictly better") } } func TestAdvTreasureIrreplaceable(t *testing.T) { special := &AdvTreasureDef{Bonuses: []advTreasureBonusDef{{Type: "special_respawn_halve", Value: 1}}} mixed := &AdvTreasureDef{Bonuses: []advTreasureBonusDef{ {Type: "all_skills", Value: 5}, {Type: "special_monthly_death_bypass", Value: 1}, }} regular := &AdvTreasureDef{Bonuses: []advTreasureBonusDef{{Type: "loot_quality", Value: 10}}} none := &AdvTreasureDef{} if !advTreasureIrreplaceable(special) { t.Error("special_* bonus should mark irreplaceable") } if !advTreasureIrreplaceable(mixed) { t.Error("any special_* among bonuses should mark irreplaceable") } if advTreasureIrreplaceable(regular) { t.Error("regular bonuses should not be irreplaceable") } if advTreasureIrreplaceable(none) { t.Error("zero bonuses is not irreplaceable") } if advTreasureIrreplaceable(nil) { t.Error("nil should not be irreplaceable") } } // ── Crafting Teaser Boundaries ───────────────────────────────────────────── func TestRenderCraftingTeaser_BracketBoundaries(t *testing.T) { cases := []struct { name string foraging int craftsSucceeded int wantEmpty bool wantContainsAny []string // any of these (OR) wantNotContains []string }{ {"foraging 6 — out of pre-window", 6, 0, true, nil, nil}, {"foraging 7 — pre-unlock with 3 levels left, plural", 7, 0, false, []string{"3 Foraging level"}, []string{"unlocks in 1 ", "unlocks in 0"}}, {"foraging 9 — pre-unlock with 1 level, singular", 9, 0, false, []string{"1 Foraging level"}, []string{"levels"}}, {"foraging 10, no crafts yet — unlocked nudge", 10, 0, false, []string{"Crafting is unlocked"}, []string{"unlocks in"}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got := craftingTeaserText(tc.foraging, tc.craftsSucceeded, "") if tc.wantEmpty { if got != "" { t.Errorf("expected empty, got %q", got) } return } if got == "" { t.Fatalf("expected non-empty teaser, got empty") } for _, s := range tc.wantContainsAny { if strings.Contains(got, s) { goto matched } } t.Errorf("expected any of %v, got %q", tc.wantContainsAny, got) matched: for _, bad := range tc.wantNotContains { if strings.Contains(got, bad) { t.Errorf("output should not contain %q, got %q", bad, got) } } }) } } func TestCraftingReminderWeekday_Spread(t *testing.T) { // Across many synthetic users in the same week, the per-player weekday // should land on most weekdays (uniform-ish hash). Asserting "≥ 4 of 7" // is a loose sanity check that catches a constant or pathological hash. now := time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC) hits := map[int]bool{} for i := 0; i < 100; i++ { uid := id.UserID(strings.Repeat("a", i+1) + "@test:local") hits[craftingReminderWeekday(uid, now)] = true } if len(hits) < 5 { t.Errorf("expected weekday spread across most days, only hit %d distinct: %v", len(hits), hits) } } func TestCraftingReminderWeekday_StableWithinWeek(t *testing.T) { uid := id.UserID("@stable:test") mon := time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC) // Monday sun := time.Date(2026, 5, 10, 23, 0, 0, 0, time.UTC) // Sunday same ISO week if craftingReminderWeekday(uid, mon) != craftingReminderWeekday(uid, sun) { t.Error("weekday should be stable within an ISO week") } // Following ISO week — should differ for at least *some* users; not // guaranteed for every user but we test the rotation property exists. nextWeek := time.Date(2026, 5, 11, 0, 0, 0, 0, time.UTC) differs := 0 for i := 0; i < 50; i++ { u := id.UserID(strings.Repeat("u", i+1) + "@x") if craftingReminderWeekday(u, mon) != craftingReminderWeekday(u, nextWeek) { differs++ } } if differs == 0 { t.Error("expected the per-week hash to rotate at least some users between weeks") } } // ── Location Risk/Reward Numbers ─────────────────────────────────────────── func TestCalculateAdvProbabilities_RiskReward_NormalizesAcrossConfigs(t *testing.T) { // Risk/reward render line depends on these probabilities. Lock in that // they normalize to 100 across a few realistic configurations beyond // the existing single-config sum test. cases := []struct { name string char *AdventureCharacter loc *AdvLocation }{ {"low-tier with low skill", &AdventureCharacter{ForagingSkill: 3}, &AdvLocation{Activity: AdvActivityForaging, Tier: 1, BaseDeathPct: 5, EmptyPct: 30}}, {"high-tier with high skill", &AdventureCharacter{ForagingSkill: 30}, &AdvLocation{Activity: AdvActivityForaging, Tier: 4, BaseDeathPct: 25, EmptyPct: 20}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { equip := map[EquipmentSlot]*AdvEquipment{ SlotTool: {Tier: 3, Condition: 100}, } probs := calculateAdvProbabilities(tc.char, equip, tc.loc, &AdvBonusSummary{}, false) total := probs.DeathPct + probs.EmptyPct + probs.SuccessPct + probs.ExceptionalPct if total < 99.99 || total > 100.01 { t.Errorf("probabilities should sum to 100, got %.2f (%+v)", total, probs) } if probs.DeathPct < 0 || probs.ExceptionalPct < 0 { t.Errorf("negative probability: %+v", probs) } }) } }