Files
gogobee/internal/plugin/dnd_economy_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

235 lines
7.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
}