Files
gogobee/internal/plugin/dnd_economy_test.go
prosolis d9541f07f1 adventure: Tier 6 postgame "Mythic" dungeons (P1–P7 calibration)
Five post-game dungeons above the T5 ceiling, gated on both T5 bosses beaten
+ level 18. Opt-in endgame: deadly solo, clearable by a party with Pete + pets.

- P1 gating: postgameUnlocked (T5 clears + level floor), zonesForLevel excludes
  T6 unconditionally; wired into startZoneRun, !zone/!expedition, party accept,
  boredom picker, and the list dividers.
- P2 bestiary: 15 elites + 5 signature bosses (Layer-1 stat blocks).
- P3 zone defs + 4-region registries; ZoneLootEntry.BossOnly.
- P4 five zone graphs on a shared builder (44–52 rooms, no soft-lock; Ossuary
  secret Verse nodes).
- P5 loot: BossOnly enforced; signature items are real registry magic items;
  five Thom pity recipes off the per-zone crafting anchors.
- P6 narration/flavor (5 files), T6 achievements, Pete stays zone-parametric.
- P7 (in progress): sim can now reach gated T6 (SimRunner.SeedPostgameUnlock +
  IsPostgameZone). First calibration pass on millenia — hardened ossuary +
  drowned_star, softened first_hoard + unplace; last_meridian in band.

Fix: party members were refused from every T6 zone because expeditionCmdAccept
ran the level gate (which excludes T6) before the postgame check — the intended
party endgame was unreachable. Route T6 through postgameUnlocked. Regression
tests added.
2026-07-15 23:17:07 -07:00

287 lines
9.4 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 {
// AlwaysKnown pity recipes consume zone-loot crafting anchors, not
// gather-resource materials — validated separately below.
if r.AlwaysKnown {
continue
}
for ing := range r.Ingredients {
if !known[ing] {
t.Errorf("recipe %s references unknown ingredient %q", r.ID, ing)
}
}
}
}
// TestPityRecipes_AnchorsAndOutputsResolve — the T6 signature-item pity
// recipes consume a zone-loot UniqueAlways crafting anchor and emit a real
// registry magic item. Validate both ends: every ingredient name matches a
// UniqueAlways zone-loot drop's inventory name, and every OutputSkillSource
// points at a magicItemRegistry ID.
func TestPityRecipes_AnchorsAndOutputsResolve(t *testing.T) {
anchorNames := map[string]bool{}
for _, z := range allZones() {
for _, e := range z.Loot {
if e.UniqueAlways {
anchorNames[titleCaseUnderscored(e.ItemID)] = true
}
}
}
pity := 0
for _, r := range thomCraftRecipes {
if !r.AlwaysKnown {
continue
}
pity++
for ing := range r.Ingredients {
if !anchorNames[ing] {
t.Errorf("pity recipe %s ingredient %q is not a zone-loot anchor name", r.ID, ing)
}
}
if r.OutputType != "magic_item" {
t.Errorf("pity recipe %s OutputType = %q, want magic_item", r.ID, r.OutputType)
}
id, ok := strings.CutPrefix(r.OutputSkillSource, "magic_item:")
if !ok {
t.Errorf("pity recipe %s OutputSkillSource %q missing magic_item: prefix", r.ID, r.OutputSkillSource)
continue
}
if _, ok := magicItemRegistry[id]; !ok {
t.Errorf("pity recipe %s output %q not in magicItemRegistry", r.ID, id)
}
}
if pity != 5 {
t.Errorf("expected 5 T6 pity recipes, found %d", pity)
}
}
// 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 {
// Pity recipes emit Legendary magic items priced well above the §8.1
// crafting band by design — excluded from this invariant.
if r.AlwaysKnown {
continue
}
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()
}