mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 08:52:42 +00:00
Adv 2.0 D&D layer: Phases 1-8 + Phase 9 SP1+SP2
Combines all uncommitted D&D work into one checkpoint: Phases 1-8 (per gogobee_dnd_session_summary.md): - Race/class/stats system, !setup flow, !sheet, !respec - d20-vs-AC combat with race/class passives, auto-migration - XP/level-up, skill checks, NPC refund hooks - Equipment slot mapping, rarity, !rest short/long - Pre-arm active abilities, resource pools - Bestiary, !roll/!stats/!level, onboarding DM - Audit fixes A-I, flavor wiring under internal/flavor/ - Phase 8 weapon dice + armor AC tables (gogobee_equipment_appendix) Phase 9 SP1 — spell system foundations: - 79 SpellDefinitions (76 in-scope + 3 reaction-deferred) - dnd_known_spells, dnd_spell_slots tables - pending_cast / concentration_* columns on dnd_character - Slot tables for Mage/Cleric/Ranger, DC + attack-bonus math - Long-rest refreshes slots; respec wipes spell state - Auto-grant default known list on !setup confirm and auto-migration Phase 9 SP2 — !cast / !spells / !prepare commands: - Out-of-combat HEAL and UTILITY resolve immediately - Damage/control/buff queue as pending_cast for next combat - Audit-style save-then-debit, slot refund on save failure - !cast --drop, concentration supersession, prep-cap enforcement - Reaction spells refused with Phase 11 note SP3 (combat-time resolution of pending_cast) and SP4 (full prep/learn tests) are next. Build clean, full test suite green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
466
internal/plugin/adventure_followups_test.go
Normal file
466
internal/plugin/adventure_followups_test.go
Normal file
@@ -0,0 +1,466 @@
|
||||
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 TestResolveAdvAction_MasteryOnlyCountsRelevantSlots(t *testing.T) {
|
||||
// A foraging action should bump tool but leave weapon/armor/helmet/boots
|
||||
// untouched. The reverse for combat.
|
||||
char := &AdventureCharacter{
|
||||
CombatLevel: 5, ForagingSkill: 5, MiningSkill: 5, FishingSkill: 5,
|
||||
}
|
||||
bonuses := &AdvBonusSummary{}
|
||||
|
||||
t.Run("foraging only bumps tool", func(t *testing.T) {
|
||||
loc := &AdvLocation{Name: "T", Tier: 1, Activity: AdvActivityForaging,
|
||||
MinLevel: 1, BaseDeathPct: 1, EmptyPct: 50}
|
||||
equip := map[EquipmentSlot]*AdvEquipment{
|
||||
SlotWeapon: {Tier: 1, Condition: 100, ActionsUsed: 10},
|
||||
SlotArmor: {Tier: 1, Condition: 100, ActionsUsed: 10},
|
||||
SlotHelmet: {Tier: 1, Condition: 100, ActionsUsed: 10},
|
||||
SlotBoots: {Tier: 1, Condition: 100, ActionsUsed: 10},
|
||||
SlotTool: {Tier: 1, Condition: 100, ActionsUsed: 10},
|
||||
}
|
||||
resolveAdvAction(char, equip, loc, bonuses, false)
|
||||
if equip[SlotTool].ActionsUsed != 11 {
|
||||
t.Errorf("tool should have advanced 10→11, got %d", equip[SlotTool].ActionsUsed)
|
||||
}
|
||||
for _, slot := range []EquipmentSlot{SlotWeapon, SlotArmor, SlotHelmet, SlotBoots} {
|
||||
if equip[slot].ActionsUsed != 10 {
|
||||
t.Errorf("%s should be unchanged at 10, got %d", slot, equip[slot].ActionsUsed)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("combat only bumps combat slots", func(t *testing.T) {
|
||||
loc := &AdvLocation{Name: "T", Tier: 1, Activity: AdvActivityDungeon,
|
||||
MinLevel: 1, BaseDeathPct: 1, EmptyPct: 50}
|
||||
equip := map[EquipmentSlot]*AdvEquipment{
|
||||
SlotWeapon: {Tier: 1, Condition: 100, ActionsUsed: 10},
|
||||
SlotArmor: {Tier: 1, Condition: 100, ActionsUsed: 10},
|
||||
SlotHelmet: {Tier: 1, Condition: 100, ActionsUsed: 10},
|
||||
SlotBoots: {Tier: 1, Condition: 100, ActionsUsed: 10},
|
||||
SlotTool: {Tier: 1, Condition: 100, ActionsUsed: 10},
|
||||
}
|
||||
resolveAdvAction(char, equip, loc, bonuses, false)
|
||||
for _, slot := range []EquipmentSlot{SlotWeapon, SlotArmor, SlotHelmet, SlotBoots} {
|
||||
if equip[slot].ActionsUsed != 11 {
|
||||
t.Errorf("%s should have advanced 10→11, got %d", slot, equip[slot].ActionsUsed)
|
||||
}
|
||||
}
|
||||
if equip[SlotTool].ActionsUsed != 10 {
|
||||
t.Errorf("tool should be unchanged at 10, got %d", equip[SlotTool].ActionsUsed)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestResolveAdvAction_MasteryCrossingFiresOnceAtBoundary(t *testing.T) {
|
||||
// Action 49→50 should report a single crossing at threshold 50.
|
||||
// Action 50→51 should report none (idempotent past the boundary).
|
||||
loc := &AdvLocation{
|
||||
Name: "Test", Tier: 1, Activity: AdvActivityForaging,
|
||||
MinLevel: 1, MinEquipTier: 0, BaseDeathPct: 1, EmptyPct: 50,
|
||||
}
|
||||
char := &AdventureCharacter{
|
||||
CombatLevel: 5, ForagingSkill: 5, MiningSkill: 5, FishingSkill: 5,
|
||||
}
|
||||
bonuses := &AdvBonusSummary{}
|
||||
|
||||
mkEquip := func(used int) map[EquipmentSlot]*AdvEquipment {
|
||||
return map[EquipmentSlot]*AdvEquipment{
|
||||
SlotTool: {Tier: 1, Condition: 100, ActionsUsed: used},
|
||||
}
|
||||
}
|
||||
|
||||
res1 := resolveAdvAction(char, mkEquip(49), loc, bonuses, false)
|
||||
if got := len(res1.MasteryCrossings); got != 1 {
|
||||
t.Fatalf("49→50: expected 1 crossing, got %d", got)
|
||||
}
|
||||
if res1.MasteryCrossings[0].Threshold != 50 {
|
||||
t.Errorf("expected threshold 50, got %d", res1.MasteryCrossings[0].Threshold)
|
||||
}
|
||||
|
||||
res2 := resolveAdvAction(char, mkEquip(50), loc, bonuses, false)
|
||||
if got := len(res2.MasteryCrossings); got != 0 {
|
||||
t.Errorf("50→51: expected 0 crossings, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
char := &AdventureCharacter{
|
||||
ForagingSkill: tc.foraging,
|
||||
CraftsSucceeded: tc.craftsSucceeded,
|
||||
}
|
||||
got := renderCraftingTeaser(char)
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Co-op Teaser Leader Skip ───────────────────────────────────────────────
|
||||
|
||||
func TestPickCoopTeaserCandidate_SkipsLeader(t *testing.T) {
|
||||
me := id.UserID("@me:test")
|
||||
other := id.UserID("@other:test")
|
||||
|
||||
myRun := &CoopRun{ID: 1, Tier: 1, LeaderID: me}
|
||||
theirRunMatch := &CoopRun{ID: 2, Tier: 1, LeaderID: other}
|
||||
theirRunStretch := &CoopRun{ID: 3, Tier: 5, LeaderID: other}
|
||||
|
||||
char := &AdventureCharacter{UserID: me, CombatLevel: 10}
|
||||
|
||||
// Player leads run 1, qualifies for run 2 (T1 minLevel 5), under-level for run 3 (T5 minLevel 40).
|
||||
match, stretch := pickCoopTeaserCandidate([]*CoopRun{myRun, theirRunMatch, theirRunStretch}, char)
|
||||
if match == nil || match.ID != 2 {
|
||||
t.Errorf("expected match=run 2, got %v", match)
|
||||
}
|
||||
if stretch == nil || stretch.ID != 3 {
|
||||
t.Errorf("expected stretch=run 3, got %v", stretch)
|
||||
}
|
||||
|
||||
// Only own run open: nothing to surface.
|
||||
match, stretch = pickCoopTeaserCandidate([]*CoopRun{myRun}, char)
|
||||
if match != nil || stretch != nil {
|
||||
t.Errorf("expected both nil when only own run open; got match=%v stretch=%v", match, stretch)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user