Files
gogobee/internal/plugin/dnd_zone_fish_test.go
prosolis c170adaf05 Adv 2.0 D&D Phase R R3-R5: Combat-link, zone loot, fishing, economy
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>
2026-05-09 14:25:22 -07:00

197 lines
6.6 KiB
Go

package plugin
import (
"math/rand/v2"
"testing"
)
// ── §6.1 Fishing zone gate ─────────────────────────────────────────────────
func TestIsFishingZone_AllowList(t *testing.T) {
allowed := map[ZoneID]bool{
ZoneForestShadows: true,
ZoneSunkenTemple: true,
ZoneUnderdark: true,
ZoneFeywildCrossing: true,
}
all := []ZoneID{
ZoneGoblinWarrens, ZoneCryptValdris, ZoneForestShadows,
ZoneSunkenTemple, ZoneManorBlackspire, ZoneUnderforge,
ZoneUnderdark, ZoneFeywildCrossing, ZoneDragonsLair, ZoneAbyssPortal,
}
for _, z := range all {
if got, want := isFishingZone(z), allowed[z]; got != want {
t.Errorf("isFishingZone(%s) = %v, want %v", z, got, want)
}
}
}
// ── §6.2 fishing skill bonus folds in legacy rank ──────────────────────────
func TestFishingSkillBonus_Bands(t *testing.T) {
cases := []struct {
skill int
want int
}{
{0, 0}, {1, 0}, {4, 0}, {5, 1}, {9, 1}, {10, 2}, {15, 2}, {99, 2},
}
for _, c := range cases {
if got := fishingSkillBonus(c.skill); got != c.want {
t.Errorf("fishingSkillBonus(%d) = %d, want %d", c.skill, got, c.want)
}
}
}
// ── §6.2 Ranger +20% rare-catch upgrade ────────────────────────────────────
func TestRangerRareCatchUpgrade_OnlyAppliesToFish(t *testing.T) {
// Non-fish action: never upgrade, even for rangers.
rng := rand.New(rand.NewPCG(1, 1))
for i := 0; i < 100; i++ {
got := rangerRareCatchUpgrade(ClassRanger, HarvestForage, OutcomeStandard, rng)
if got != OutcomeStandard {
t.Fatalf("non-fish action upgraded: %s", got)
}
}
// Non-ranger fish: never upgrade.
rng = rand.New(rand.NewPCG(2, 2))
for i := 0; i < 100; i++ {
got := rangerRareCatchUpgrade(ClassFighter, HarvestFish, OutcomeStandard, rng)
if got != OutcomeStandard {
t.Fatalf("non-ranger upgraded: %s", got)
}
}
// Ranger fish + Rich/Nothing: passes through unchanged.
rng = rand.New(rand.NewPCG(3, 3))
for i := 0; i < 100; i++ {
if got := rangerRareCatchUpgrade(ClassRanger, HarvestFish, OutcomeRich, rng); got != OutcomeRich {
t.Fatalf("Rich changed: %s", got)
}
if got := rangerRareCatchUpgrade(ClassRanger, HarvestFish, OutcomeNothing, rng); got != OutcomeNothing {
t.Fatalf("Nothing changed: %s", got)
}
}
}
func TestRangerRareCatchUpgrade_RoughRate(t *testing.T) {
rng := rand.New(rand.NewPCG(7, 7))
const trials = 4000
upgrades := 0
for i := 0; i < trials; i++ {
out := rangerRareCatchUpgrade(ClassRanger, HarvestFish, OutcomeStandard, rng)
if out == OutcomeRich {
upgrades++
}
}
rate := float64(upgrades) / float64(trials)
if rate < 0.15 || rate > 0.25 {
t.Errorf("upgrade rate %.3f outside expected ~0.20 ±0.05", rate)
}
}
// ── §6.1 Feywild Time Distortion narration hook ─────────────────────────────
func TestFeywildFishDistortion_OnlyFiresInFeywildOnFish(t *testing.T) {
rng := rand.New(rand.NewPCG(11, 11))
for i := 0; i < 200; i++ {
if got := feywildFishDistortion(ZoneForestShadows, HarvestFish, rng); got != "" {
t.Fatalf("non-feywild fired: %q", got)
}
if got := feywildFishDistortion(ZoneFeywildCrossing, HarvestForage, rng); got != "" {
t.Fatalf("non-fish action fired: %q", got)
}
}
}
// ── §3 fish entries present in registry per §6.1 zones ─────────────────────
func TestZoneFishRegistry_Coverage(t *testing.T) {
want := map[ZoneID][]string{
ZoneForestShadows: {"shadow_trout", "gloomfin", "phantom_carp"},
ZoneSunkenTemple: {"black_pearl", "blind_cave_fish", "merrow_eel", "dareth_lanternfish", "aboleth_spawn"},
ZoneUnderdark: {"blind_albino_bass", "glowing_cave_eel", "underdark_shark", "eyeless_king"},
ZoneFeywildCrossing: {"moonlit_minnow", "dreaming_pike", "timeworn_koi"},
}
for zid, ids := range want {
seen := map[string]bool{}
for _, r := range ZoneResourcesByAction(zid, HarvestFish) {
seen[r.ID] = true
}
for _, id := range ids {
if !seen[id] {
t.Errorf("zone %s missing fish entry %s", zid, id)
}
}
}
// And: zones not on the §6.1 list should have no fish entries.
noFish := []ZoneID{
ZoneGoblinWarrens, ZoneCryptValdris, ZoneManorBlackspire,
ZoneUnderforge, ZoneDragonsLair, ZoneAbyssPortal,
}
for _, z := range noFish {
if got := ZoneResourcesByAction(z, HarvestFish); len(got) != 0 {
t.Errorf("zone %s has %d fish entries, want 0", z, len(got))
}
}
}
// ── §5 zone-contextual item flavor matrix ──────────────────────────────────
func TestZoneItemDescription_LootMatrixCoverage(t *testing.T) {
// Every zone's loot table entry name should resolve to either a
// flavor line or the Potion of Healing exemplar — but the specific
// invariant we want is: every zone has *some* per-item flavor populated
// beyond just Potion of Healing.
zones := []ZoneID{
ZoneGoblinWarrens, ZoneCryptValdris, ZoneForestShadows,
ZoneSunkenTemple, ZoneManorBlackspire, ZoneUnderforge,
ZoneUnderdark, ZoneFeywildCrossing, ZoneDragonsLair, ZoneAbyssPortal,
}
for _, z := range zones {
zm, ok := zoneItemFlavor[z]
if !ok || len(zm) == 0 {
t.Errorf("zone %s: no item flavor matrix populated", z)
continue
}
// Every flavor entry should resolve via the public path.
for name, want := range zm {
if got := zoneItemDescription(z, name); got != want {
t.Errorf("zone %s item %q: zoneItemDescription mismatch", z, name)
}
}
}
}
func TestZoneItemDescription_MissingItemReturnsEmpty(t *testing.T) {
if got := zoneItemDescription(ZoneGoblinWarrens, "Nonexistent Item"); got != "" {
t.Errorf("got %q, want empty for unknown item", got)
}
}
// ── Fish entries respect §8.1 sell-value bands ──────────────────────────────
func TestZoneFish_SellValueBands(t *testing.T) {
bands := map[DnDRarity][2]int{
RarityCommon: {3, 20},
RarityUncommon: {25, 70},
RarityRare: {100, 350},
RarityVeryRare: {500, 1500},
RarityLegendary: {3000, 10000},
}
zones := []ZoneID{
ZoneForestShadows, ZoneSunkenTemple, ZoneUnderdark, ZoneFeywildCrossing,
}
for _, z := range zones {
for _, r := range ZoneResourcesByAction(z, HarvestFish) {
band, ok := bands[r.Rarity]
if !ok {
t.Errorf("fish %s has unhandled rarity %s", r.ID, r.Rarity)
continue
}
if r.SellValue < band[0] || r.SellValue > band[1] {
t.Errorf("fish %s sell %d outside band %v for %s", r.ID, r.SellValue, band, r.Rarity)
}
}
}
}