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>
This commit is contained in:
prosolis
2026-05-08 18:31:27 -07:00
parent 83cdf07374
commit c170adaf05
13 changed files with 2507 additions and 0 deletions

View File

@@ -0,0 +1,234 @@
package plugin
import (
"strings"
"testing"
)
// ── Recipe registry coverage ─────────────────────────────────────────────────
// TestRecipeRegistry_IngredientsExistInResources — every ingredient name
// referenced by a §8.2 recipe must match a Name in the zone-resource
// registry, otherwise !craft can never find the inventory items.
func TestRecipeRegistry_IngredientsExistInResources(t *testing.T) {
known := map[string]bool{}
for _, list := range zoneResources {
for _, r := range list {
known[r.Name] = true
}
}
for _, r := range thomCraftRecipes {
for ing := range r.Ingredients {
if !known[ing] {
t.Errorf("recipe %s references unknown ingredient %q", r.ID, ing)
}
}
}
}
// TestRecipeRegistry_OutputValuesMatchTier — outputs should land in §8.1
// rarity brackets given their tier (rare 100300, very rare 5001200,
// epic 5001200 in §8.1 — recipes here lean tier 34, so 200..1200 is the
// reasonable band).
func TestRecipeRegistry_OutputValuesInBand(t *testing.T) {
for _, r := range thomCraftRecipes {
if r.OutputValue < 200 || r.OutputValue > 1200 {
t.Errorf("recipe %s output value %d outside band [200,1200] (tier %d)",
r.ID, r.OutputValue, r.OutputTier)
}
}
}
// TestRecipeRegistry_AllExemplarsPresent — the four implementable §8.2
// exemplars must be in the registry. Drow Poison Blade is intentionally
// deferred (needs "any blade weapon" matching, lands in R6).
func TestRecipeRegistry_AllExemplarsPresent(t *testing.T) {
want := []string{
"potion_fire_resist",
"mithral_dagger",
"undead_ward_charm",
"shadow_step_cloak",
}
have := map[string]bool{}
for _, r := range thomCraftRecipes {
have[r.ID] = true
}
for _, id := range want {
if !have[id] {
t.Errorf("missing exemplar recipe: %s", id)
}
}
}
// ── findRecipeByName ─────────────────────────────────────────────────────────
func TestFindRecipeByName_FuzzyMatch(t *testing.T) {
cases := []struct {
query string
want string
ok bool
}{
{"mithral", "mithral_dagger", true},
{"shadow step", "shadow_step_cloak", true},
{"Potion of Fire", "potion_fire_resist", true},
{"nonexistent", "", false},
{"", "", false},
}
for _, c := range cases {
got, ok := findRecipeByName(c.query)
if ok != c.ok {
t.Errorf("query %q: ok=%v want %v", c.query, ok, c.ok)
continue
}
if ok && got.ID != c.want {
t.Errorf("query %q: got %s want %s", c.query, got.ID, c.want)
}
}
}
// ── pickIngredients ──────────────────────────────────────────────────────────
func TestPickIngredients_ExactCover(t *testing.T) {
items := []AdvItem{
{ID: 1, Name: "Salamander Oil", Type: "material"},
{ID: 2, Name: "Fire Essence", Type: "material"},
{ID: 3, Name: "Fire Essence", Type: "material"},
{ID: 4, Name: "Scrap Iron", Type: "material"},
}
need := map[string]int{"Salamander Oil": 1, "Fire Essence": 2}
consume, missing := pickIngredients(items, need)
if len(missing) != 0 {
t.Errorf("missing should be empty, got %v", missing)
}
if len(consume) != 3 {
t.Errorf("consume len = %d want 3", len(consume))
}
}
func TestPickIngredients_Short(t *testing.T) {
items := []AdvItem{
{ID: 1, Name: "Fire Essence", Type: "material"},
}
need := map[string]int{"Fire Essence": 2, "Salamander Oil": 1}
consume, missing := pickIngredients(items, need)
if missing["Fire Essence"] != 1 {
t.Errorf("missing Fire Essence = %d want 1", missing["Fire Essence"])
}
if missing["Salamander Oil"] != 1 {
t.Errorf("missing Salamander Oil = %d want 1", missing["Salamander Oil"])
}
// We still consumed the one match optimistically; caller checks missing.
if len(consume) != 1 {
t.Errorf("consume len = %d want 1", len(consume))
}
}
// ── filterSellable ───────────────────────────────────────────────────────────
func TestFilterSellable_ExcludesEquipment(t *testing.T) {
items := []AdvItem{
{Name: "Fire Essence", Type: "material"},
{Name: "Shadow Trout", Type: "fish"},
{Name: "Cursed Trinket", Type: "item"},
{Name: "Mithril Sword", Type: "MasterworkGear"},
{Name: "Arena Helm", Type: "ArenaGear"},
{Name: "Healing Potion", Type: "consumable"},
{Name: "Lucky Card", Type: "card"},
}
out := filterSellable(items)
if len(out) != 3 {
t.Errorf("filterSellable len = %d want 3 (material/fish/item only)", len(out))
}
for _, it := range out {
switch it.Type {
case "material", "fish", "item":
default:
t.Errorf("unexpected type in sellable: %s", it.Type)
}
}
}
// ── findInventoryMatch ──────────────────────────────────────────────────────
func TestFindInventoryMatch_FuzzyAndMiss(t *testing.T) {
items := []AdvItem{
{ID: 1, Name: "Salamander Oil"},
{ID: 2, Name: "Fire Essence"},
}
if m := findInventoryMatch(items, "salamander"); m == nil || m.ID != 1 {
t.Errorf("want match on Salamander Oil")
}
if m := findInventoryMatch(items, "FIRE"); m == nil || m.ID != 2 {
t.Errorf("case-insensitive match should hit Fire Essence")
}
if m := findInventoryMatch(items, "mithral"); m != nil {
t.Errorf("expected no match for 'mithral'")
}
}
// ── inventoryByName ─────────────────────────────────────────────────────────
func TestInventoryByName_Counts(t *testing.T) {
items := []AdvItem{
{Name: "Fire Essence"}, {Name: "Fire Essence"}, {Name: "Fire Essence"},
{Name: "Salamander Oil"},
}
got := inventoryByName(items)
if got["Fire Essence"] != 3 || got["Salamander Oil"] != 1 {
t.Errorf("counts off: %v", got)
}
}
// ── Recipe book rendering ───────────────────────────────────────────────────
// TestRenderRecipeBook_HidesUndiscovered — discovery state must hide
// recipe details until they're known.
func TestRenderRecipeBook_HidesUndiscovered(t *testing.T) {
known := map[string]bool{}
out := renderRecipeBookForTest(known, nil)
for _, r := range thomCraftRecipes {
if strings.Contains(out, r.Name) {
t.Errorf("undiscovered recipe %s leaked into book", r.Name)
}
}
if !strings.Contains(out, "undiscovered") {
t.Errorf("expected hidden-recipe count in output:\n%s", out)
}
known = map[string]bool{"mithral_dagger": true}
out = renderRecipeBookForTest(known, nil)
if !strings.Contains(out, "Mithral Dagger") {
t.Errorf("known recipe Mithral Dagger missing:\n%s", out)
}
if strings.Contains(out, "Potion of Fire Resistance") {
t.Errorf("undiscovered recipe should be hidden")
}
}
// renderRecipeBookForTest is a DB-free version of renderRecipeBook for
// unit tests. It bypasses loadAdvInventory by accepting items directly.
func renderRecipeBookForTest(known map[string]bool, items []AdvItem) string {
var sb strings.Builder
sb.WriteString("📜 **Thom Krooke — Recipe Book**\n\n")
have := inventoryByName(items)
knownCount := 0
for _, r := range thomCraftRecipes {
if !known[r.ID] {
continue
}
knownCount++
sb.WriteString(r.Name + "\n")
var parts []string
for ing, need := range r.Ingredients {
parts = append(parts, ing)
_ = need
_ = have
}
sb.WriteString(strings.Join(parts, " + ") + "\n")
}
hidden := len(thomCraftRecipes) - knownCount
if hidden > 0 {
sb.WriteString("undiscovered\n")
}
return sb.String()
}