mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
R3 — Combat Event Integration: - dnd_expedition_combat.go: Combat Interrupt rolls (§4.2) with threat-clock and Ranger-wilderness modifiers; Patrol Encounters scaled by threat level; recordZoneKill writer with monsterKillTags. - Interrupt gate at head of handleHarvestCmd; patrol gate before resolveRoom in zoneCmdAdvance; kill writer wired into combat-win paths. R4a — Zone Loot Tables: - dnd_zone_loot.go: §5 loot drop tables for all 10 zones × 5 tiers with §8.1 sell-value bands. dropTierFromCR brackets + boss floor. - Hooks on combat-win in resolveCombatRoom, resolveBossRoom, runHarvestInterrupt, tryPatrolEncounter. R4b — Fishing Integration: - dnd_zone_fish.go: fishingZones allow-list, fishingSkillBonus, rangerRareCatchUpgrade (§6.2), feywildFishDistortion narration. - §6.1 fish entries added to resource registry for Forest, Sunken Temple, Underdark, Feywild zones. - !fish wired through handleHarvestCmd; zoneItemFlavor matrix populated for all 10 zones × all loot items. R5 — Economy Integration: - dnd_economy.go: !sell (post-expedition gate, single CHA Persuasion DC 17 → +15% bump), !craft (§8.2 4 exemplar recipes), !lore (INT/Arcana DC 15 recipe discovery). - dnd_known_recipe table for persistent recipe discovery. Flavor reuse: HarvestInterrupt, PatrolEncounter, CombatVictory, PlayerDeath, LootDrop*, FeywildTimeDistortion* — all existing pools. No new flavor file. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
173 lines
5.5 KiB
Go
173 lines
5.5 KiB
Go
package plugin
|
|
|
|
import (
|
|
"math/rand/v2"
|
|
"testing"
|
|
)
|
|
|
|
// ── §5 Drop tier brackets ──────────────────────────────────────────────────
|
|
|
|
func TestDropTierFromCR_Brackets(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
cr float32
|
|
isBoss bool
|
|
wantGuaranteed LootTier
|
|
wantBonus LootTier
|
|
wantDropChance float64
|
|
}{
|
|
{"low cr no guarantee", 0.5, false, LootTierCommon, "", 0.70},
|
|
{"cr 1 still common only", 1.0, false, LootTierCommon, "", 0.70},
|
|
{"cr 2 guarantees common", 2.0, false, LootTierCommon, LootTierUncommon, 1.0},
|
|
{"cr 5 uncommon", 5.0, false, LootTierUncommon, LootTierRare, 1.0},
|
|
{"cr 9 rare", 9.0, false, LootTierRare, LootTierEpic, 1.0},
|
|
{"cr 13 epic", 13.0, false, LootTierEpic, LootTierLegendary, 1.0},
|
|
{"boss min rare", 4.0, true, LootTierRare, LootTierEpic, 1.0},
|
|
{"high cr boss still rare floor", 20.0, true, LootTierRare, LootTierEpic, 1.0},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
g, b, _, dc := dropTierFromCR(c.cr, c.isBoss)
|
|
if g != c.wantGuaranteed || b != c.wantBonus || dc != c.wantDropChance {
|
|
t.Errorf("got (%s,%s,%g) want (%s,%s,%g)",
|
|
g, b, dc, c.wantGuaranteed, c.wantBonus, c.wantDropChance)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ── Loot table coverage ─────────────────────────────────────────────────────
|
|
|
|
func TestZoneLootTables_AllZonesAllTiers(t *testing.T) {
|
|
zones := []ZoneID{
|
|
ZoneGoblinWarrens, ZoneCryptValdris, ZoneForestShadows,
|
|
ZoneSunkenTemple, ZoneManorBlackspire, ZoneUnderforge,
|
|
ZoneUnderdark, ZoneFeywildCrossing, ZoneDragonsLair, ZoneAbyssPortal,
|
|
}
|
|
tiers := []LootTier{
|
|
LootTierCommon, LootTierUncommon, LootTierRare, LootTierEpic, LootTierLegendary,
|
|
}
|
|
for _, z := range zones {
|
|
table, ok := zoneLootTables[z]
|
|
if !ok {
|
|
t.Errorf("zone %s missing loot table", z)
|
|
continue
|
|
}
|
|
for _, tier := range tiers {
|
|
if len(table[tier]) == 0 {
|
|
t.Errorf("zone %s has empty %s slate", z, tier)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestZoneLootTables_SellValuesInRarityBrackets(t *testing.T) {
|
|
bands := map[LootTier][2]int{
|
|
LootTierCommon: {5, 15},
|
|
LootTierUncommon: {25, 60},
|
|
LootTierRare: {100, 300},
|
|
LootTierEpic: {500, 1200},
|
|
LootTierLegendary: {3000, 10000},
|
|
}
|
|
for zid, table := range zoneLootTables {
|
|
for tier, slate := range table {
|
|
lo, hi := bands[tier][0], bands[tier][1]
|
|
for _, e := range slate {
|
|
if e.BaseValue < lo || e.BaseValue > hi {
|
|
t.Errorf("zone %s tier %s item %s value %d outside [%d,%d]",
|
|
zid, tier, e.Name, e.BaseValue, lo, hi)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── rollZoneLoot behavior ───────────────────────────────────────────────────
|
|
|
|
func TestRollZoneLoot_LowCRRespectsDropChance(t *testing.T) {
|
|
// CR 0.5 has 70% drop chance — over many rolls we should see misses.
|
|
rng := rand.New(rand.NewPCG(1, 2))
|
|
misses := 0
|
|
hits := 0
|
|
for i := 0; i < 1000; i++ {
|
|
_, _, ok := rollZoneLoot(ZoneGoblinWarrens, 0.5, false, rng)
|
|
if ok {
|
|
hits++
|
|
} else {
|
|
misses++
|
|
}
|
|
}
|
|
if misses == 0 {
|
|
t.Errorf("expected some misses at CR 0.5, got 0/%d", hits+misses)
|
|
}
|
|
if hits == 0 {
|
|
t.Errorf("expected some hits at CR 0.5, got 0/%d", hits+misses)
|
|
}
|
|
}
|
|
|
|
func TestRollZoneLoot_BossAlwaysDropsRareOrBetter(t *testing.T) {
|
|
rng := rand.New(rand.NewPCG(7, 9))
|
|
for i := 0; i < 200; i++ {
|
|
_, tier, ok := rollZoneLoot(ZoneAbyssPortal, 20, true, rng)
|
|
if !ok {
|
|
t.Fatalf("boss drop missed (i=%d)", i)
|
|
}
|
|
if tier != LootTierRare && tier != LootTierEpic {
|
|
t.Fatalf("boss tier = %s, want Rare or Epic", tier)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRollZoneLoot_HighCROccasionallyUpgradesToLegendary(t *testing.T) {
|
|
rng := rand.New(rand.NewPCG(11, 13))
|
|
saw := false
|
|
for i := 0; i < 5000; i++ {
|
|
_, tier, ok := rollZoneLoot(ZoneDragonsLair, 15, false, rng)
|
|
if ok && tier == LootTierLegendary {
|
|
saw = true
|
|
break
|
|
}
|
|
}
|
|
if !saw {
|
|
t.Errorf("expected at least one Legendary upgrade at CR 15 over 5000 rolls")
|
|
}
|
|
}
|
|
|
|
func TestRollZoneLoot_UnknownZoneReturnsFalse(t *testing.T) {
|
|
rng := rand.New(rand.NewPCG(1, 1))
|
|
_, _, ok := rollZoneLoot(ZoneID("nonexistent_zone"), 5, false, rng)
|
|
if ok {
|
|
t.Errorf("expected ok=false for unknown zone")
|
|
}
|
|
}
|
|
|
|
// ── Zone-contextual flavor ──────────────────────────────────────────────────
|
|
|
|
func TestZoneItemDescription_PotionOfHealingAllZones(t *testing.T) {
|
|
zones := []ZoneID{
|
|
ZoneGoblinWarrens, ZoneCryptValdris, ZoneForestShadows,
|
|
ZoneSunkenTemple, ZoneManorBlackspire, ZoneUnderforge,
|
|
ZoneUnderdark, ZoneFeywildCrossing, ZoneDragonsLair, ZoneAbyssPortal,
|
|
}
|
|
for _, z := range zones {
|
|
desc := zoneItemDescription(z, "Potion of Healing")
|
|
if desc == "" {
|
|
t.Errorf("zone %s has no Potion of Healing flavor", z)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestZoneItemDescription_NonMatchingItemReturnsEmpty(t *testing.T) {
|
|
if got := zoneItemDescription(ZoneGoblinWarrens, "Random Junk"); got != "" {
|
|
t.Errorf("expected empty for unmapped item, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestLootFlavorLine_AllTiersPickSomething(t *testing.T) {
|
|
for _, tier := range []LootTier{LootTierCommon, LootTierUncommon, LootTierRare, LootTierEpic, LootTierLegendary} {
|
|
if got := lootFlavorLine(tier); got == "" {
|
|
t.Errorf("tier %s produced empty flavor line", tier)
|
|
}
|
|
}
|
|
}
|