diff --git a/internal/plugin/dnd_economy.go b/internal/plugin/dnd_economy.go index d545c19..fdc7845 100644 --- a/internal/plugin/dnd_economy.go +++ b/internal/plugin/dnd_economy.go @@ -30,15 +30,18 @@ import ( // ThomCraftRecipe is one §8.2 recipe. Ingredient names match the registry // Name field exactly so inventory lookups succeed without fuzzy matching. type ThomCraftRecipe struct { - ID string - Name string - Ingredients map[string]int // Name → count - OutputName string - OutputType string // material | item | weapon | armor | accessory | consumable - OutputTier int - OutputValue int64 - Description string - DiscoveryHint string // shown when newly discovered via !lore + ID string + Name string + Ingredients map[string]int // Name → count (exact match) + // BladeWeaponCount is the count of generic "any blade weapon" + // inventory items consumed (§8.2 Drow Poison Blade). 0 = none. + BladeWeaponCount int + OutputName string + OutputType string // material | item | weapon | armor | accessory | consumable + OutputTier int + OutputValue int64 + Description string + DiscoveryHint string // shown when newly discovered via !lore } var thomCraftRecipes = []ThomCraftRecipe{ @@ -98,6 +101,43 @@ var thomCraftRecipes = []ThomCraftRecipe{ Description: "Light armor. Stealth advantage 1/day. (Effect wired in R6 polish.)", DiscoveryHint: "Thom: \"Three lengths of ghost silk woven against one drop of shadow essence. The silk fights you while you stitch. That's how you know it's working.\"", }, + { + ID: "drow_poison_blade", + Name: "Drow Poison Blade", + Ingredients: map[string]int{ + "Drow Poison (diluted)": 1, + }, + BladeWeaponCount: 1, + OutputName: "Drow Poison Blade", + OutputType: "weapon", + OutputTier: 4, + OutputValue: 540, + Description: "Coated blade — sleep-on-crit (DC 14 CON save) for 3 combats. (Effect wired in R6 polish.)", + DiscoveryHint: "Thom turns a glass vial in the lamplight. \"Drow brew, drawn down to a thin coat. Bring me any blade you trust — sword, dagger, scimitar, makes no odds. The poison picks the edge.\"", + }, +} + +// bladeWeaponKeywords are the name-substrings that qualify an AdvItem +// (Type=="weapon") as a blade for the §8.2 "any blade weapon" matcher. +var bladeWeaponKeywords = []string{ + "sword", "dagger", "blade", "scimitar", "rapier", + "knife", "cutlass", "saber", "katana", "falchion", + "shortsword", "longsword", "greatsword", +} + +// isBladeWeapon reports whether an inventory item qualifies as a blade +// for crafting purposes — type "weapon" plus a blade keyword in the name. +func isBladeWeapon(it AdvItem) bool { + if it.Type != "weapon" { + return false + } + low := strings.ToLower(it.Name) + for _, kw := range bladeWeaponKeywords { + if strings.Contains(low, kw) { + return true + } + } + return false } // findRecipeByID returns the recipe with the given ID. @@ -402,6 +442,18 @@ func renderRecipeBook(userID id.UserID, known map[string]bool) string { ready = false } } + if r.BladeWeaponCount > 0 { + haveBlades := 0 + for _, it := range items { + if isBladeWeapon(it) { + haveBlades++ + } + } + parts = append(parts, fmt.Sprintf("any blade weapon ×%d (%d)", r.BladeWeaponCount, haveBlades)) + if haveBlades < r.BladeWeaponCount { + ready = false + } + } sb.WriteString(" Ingredients: " + strings.Join(parts, " + ") + "\n") sb.WriteString(" " + r.Description + "\n") if ready { @@ -439,13 +491,19 @@ func (p *AdventurePlugin) executeCraft(userID id.UserID, r ThomCraftRecipe) erro return p.SendDM(userID, "Couldn't load inventory.") } - // Pick item IDs to consume — first-fit per ingredient name. + // Pick item IDs to consume — first-fit per ingredient name plus any + // generic "blade weapon" slots required by the recipe. consume, missing := pickIngredients(items, r.Ingredients) - if len(missing) > 0 { + bladeIDs, bladeShort := pickBladeWeapons(items, r.BladeWeaponCount, consume) + consume = append(consume, bladeIDs...) + if len(missing) > 0 || bladeShort > 0 { var lines []string for ing, short := range missing { lines = append(lines, fmt.Sprintf(" • %s — short %d", ing, short)) } + if bladeShort > 0 { + lines = append(lines, fmt.Sprintf(" • any blade weapon — short %d", bladeShort)) + } return p.SendDM(userID, fmt.Sprintf( "_Thom: \"Not enough material. Come back when you've got the rest.\"_\n\n%s", strings.Join(lines, "\n"))) @@ -473,6 +531,33 @@ func (p *AdventurePlugin) executeCraft(userID id.UserID, r ThomCraftRecipe) erro r.OutputName, r.OutputTier, r.OutputValue, r.Description)) } +// pickBladeWeapons returns inventory IDs covering the requested generic +// blade-weapon count, skipping any IDs already in `exclude`. Returns the +// chosen IDs plus a shortfall count when inventory can't cover. +func pickBladeWeapons(items []AdvItem, count int, exclude []int64) ([]int64, int) { + if count <= 0 { + return nil, 0 + } + used := map[int64]bool{} + for _, id := range exclude { + used[id] = true + } + var picked []int64 + for _, it := range items { + if len(picked) >= count { + break + } + if used[it.ID] { + continue + } + if isBladeWeapon(it) { + picked = append(picked, it.ID) + used[it.ID] = true + } + } + return picked, count - len(picked) +} + // pickIngredients walks the inventory and selects item IDs to consume, // covering all required (Name → count). Returns (idsToRemove, missingByName). func pickIngredients(items []AdvItem, need map[string]int) ([]int64, map[string]int) { diff --git a/internal/plugin/dnd_expedition_harvest.go b/internal/plugin/dnd_expedition_harvest.go index 82c4587..f013d2f 100644 --- a/internal/plugin/dnd_expedition_harvest.go +++ b/internal/plugin/dnd_expedition_harvest.go @@ -366,6 +366,10 @@ func (p *AdventurePlugin) handleHarvestCmd(ctx MessageContext, action HarvestAct prettyRoomType(run.CurrentRoomType()))) } + if blockReason := zoneConditionHarvestBlock(exp, action); blockReason != "" { + return p.SendDM(ctx.Sender, blockReason) + } + roomIdx := run.CurrentRoom nodes := loadHarvestNodes(exp, roomIdx) idx := pickAvailableNode(nodes, exp, char, action) @@ -415,7 +419,9 @@ func (p *AdventurePlugin) handleHarvestCmd(ctx MessageContext, action HarvestAct } } total := roll + mod - outcome := resolveHarvestOutcome(total, res.DC) + dcDelta, dcReason := zoneConditionHarvestDCMod(exp, action) + effectiveDC := res.DC + dcDelta + outcome := resolveHarvestOutcome(total, effectiveDC) outcome = rangerRareCatchUpgrade(char.Class, action, outcome, nil) var b strings.Builder @@ -426,8 +432,17 @@ func (p *AdventurePlugin) handleHarvestCmd(ctx MessageContext, action HarvestAct b.WriteString(dist) } verb := strings.ToTitle(string(action)[:1]) + string(action)[1:] - b.WriteString(fmt.Sprintf("**%s · %s** — d20=%d + %d = **%d** vs DC %d\n\n", - verb, res.Name, roll, mod, total, res.DC)) + if dcDelta != 0 { + sign := "+" + if dcDelta < 0 { + sign = "" + } + b.WriteString(fmt.Sprintf("**%s · %s** — d20=%d + %d = **%d** vs DC %d (%s%d %s)\n\n", + verb, res.Name, roll, mod, total, effectiveDC, sign, dcDelta, dcReason)) + } else { + b.WriteString(fmt.Sprintf("**%s · %s** — d20=%d + %d = **%d** vs DC %d\n\n", + verb, res.Name, roll, mod, total, effectiveDC)) + } switch outcome { case OutcomeNothing: @@ -463,6 +478,19 @@ func (p *AdventurePlugin) handleHarvestCmd(ctx MessageContext, action HarvestAct b.WriteString(flavor.Pick(flavor.NodeDepleted)) } + // §3 Zone 05 — Manor secondary drop on !scavenge (10% per attempt). + if rollManorCursedTrinket(exp.ZoneID, action, nil) { + if trinket, ok := FindZoneResource(exp.ZoneID, "cursed_trinket"); ok { + _ = grantHarvestYield(ctx.Sender, trinket, 1) + b.WriteString("\n\n_Something cold turns up in your kit — a small trinket you didn't pocket. **+1 Cursed Trinket.**_") + if warned, _ := exp.RegionState["manor_cursed_warned"].(bool); !warned { + b.WriteString("\n\n_TwinBee, low: \"Cursed items in this house bind to anyone who equips them. Sell them. Don't wear them. I'll only say this once.\"_") + exp.RegionState["manor_cursed_warned"] = true + _ = persistRegionState(exp) + } + } + } + if err := saveHarvestNodes(exp, roomIdx, nodes); err != nil { _ = appendExpeditionLog(exp.ID, exp.CurrentDay, "harvest", fmt.Sprintf("persistence error: %v", err), "") @@ -777,3 +805,130 @@ func retireAllRegionRuns(exp *Expedition) error { } return nil } + +// ── R6: Zone-condition harvest interactions (§3 zone notes) ────────────────── + +// zoneConditionHarvestBlock returns a human-facing block message when the +// current zone state forbids harvesting entirely (Heat 10 in Underforge, +// Infernax awake in Dragon's Lair, Instability 81+ in Abyss). Empty +// string = harvesting allowed. +func zoneConditionHarvestBlock(exp *Expedition, action HarvestAction) string { + switch exp.ZoneID { + case ZoneUnderforge: + if action == HarvestMine && exp.TemporalStack >= UnderforgeMaxHeat { + return "_The forge stones are white-hot. The hammers shake out of your hands before they bite. Mining is impossible until the heat drops._" + } + case ZoneDragonsLair: + if awake, _ := exp.RegionState["infernax_awake"].(bool); awake { + return "_Infernax is hunting these halls now. There's no time to harvest — keep moving or extract._" + } + case ZoneAbyssPortal: + if exp.TemporalStack >= 81 { + return "_The Abyss is unraveling. Reality is too loose to anchor a harvest — what you reach for slides between your fingers._" + } + } + return "" +} + +// zoneConditionHarvestDCMod returns a (deltaDC, reason) pair applied to +// the per-zone harvest roll. Reason is shown alongside the dice line. +func zoneConditionHarvestDCMod(exp *Expedition, action HarvestAction) (int, string) { + switch exp.ZoneID { + case ZoneUnderforge: + if action == HarvestMine && exp.TemporalStack >= 7 { + return 3, "heat-baked stone" + } + case ZoneDragonsLair: + // Awareness pulses fire on every DragonsLairAwarenessCycleDays. + // Pulse 3+ = day 9+. Use day count as the proxy (matches §7.5 + // pulse cadence and the spec's "Awareness Pulse 3+" reading). + pulses := 0 + if d := exp.CurrentDay; d > 0 { + pulses = d / DragonsLairAwarenessCycleDays + } + if pulses >= 3 { + return 4, "the lair is alert" + } + case ZoneAbyssPortal: + if exp.TemporalStack >= 61 && exp.TemporalStack < 81 { + return 5, "reality surges" + } + case ZoneFeywildCrossing: + if action == HarvestForage { + raw, _ := exp.RegionState["feywild_today"].(string) + if FeywildDistortion(raw) == FeywildDistortionDouble { + return -3, "double-day bloom" + } + } + } + return 0, "" +} + +// manorCursedTrinketChance is §3 Zone 05 — every !scavenge in the Manor +// has a 10% secondary-drop chance regardless of node outcome. +const manorCursedTrinketChance = 10 + +// rollManorCursedTrinket returns true when a !scavenge in the Manor +// should also yield a Cursed Trinket. rng==nil falls back to math/rand. +func rollManorCursedTrinket(zoneID ZoneID, action HarvestAction, rng func(int) int) bool { + if zoneID != ZoneManorBlackspire || action != HarvestScavenge { + return false + } + roll := 0 + if rng != nil { + roll = rng(100) + } else { + roll = rand.IntN(100) + } + return roll < manorCursedTrinketChance +} + +// restoreHarvestNodesInRoom resets every node in (region, room) to its +// MaxCharges. Used by the Feywild Time Loop event (§7.4) — "previous +// harvest results in that room are nullified." +func restoreHarvestNodesInRoom(exp *Expedition, roomIdx int) bool { + if roomIdx < 0 { + return false + } + nodes := loadHarvestNodes(exp, roomIdx) + if len(nodes) == 0 { + return false + } + changed := false + for i := range nodes { + if nodes[i].CurrentCharges < nodes[i].MaxCharges { + nodes[i].CurrentCharges = nodes[i].MaxCharges + changed = true + } + } + if !changed { + return false + } + // Write the mutated nodes back into the in-memory table; the + // briefing/cycle flow persists RegionState downstream. Avoiding the + // persist call here keeps the helper test-friendly without a DB. + regionKey := regionHarvestKey(exp) + roomKey := roomHarvestKey(roomIdx) + table := loadHarvestTable(exp) + regionMap := table[regionKey] + if regionMap == nil { + regionMap = map[string][]HarvestNode{} + } + regionMap[roomKey] = nodes + table[regionKey] = regionMap + exp.RegionState[regionStateHarvestKey] = table + return true +} + +// currentRoomIndexFor returns the room index of the active region run +// for an expedition, or -1 if no run is linked or the run can't load. +func currentRoomIndexFor(e *Expedition) int { + if e == nil || e.RunID == "" { + return -1 + } + run, err := getZoneRun(e.RunID) + if err != nil || run == nil { + return -1 + } + return run.CurrentRoom +} diff --git a/internal/plugin/dnd_expedition_temporal.go b/internal/plugin/dnd_expedition_temporal.go index c4ae75f..4d35d0c 100644 --- a/internal/plugin/dnd_expedition_temporal.go +++ b/internal/plugin/dnd_expedition_temporal.go @@ -372,7 +372,14 @@ func feywildTemporalPostRollover(e *Expedition) []string { _ = appendExpeditionLog(e.ID, e.CurrentDay, "temporal", "feywild distortion: time loop (room re-enters with new enemies; loot already taken)", line) - return []string{line} + // §3 Zone 08 — Time Loop returns previously-harvested resources + // to the current room's nodes. Combat loot stays gone; only the + // renewable harvest nodes loop back. + out := []string{line} + if restored := restoreHarvestNodesInRoom(e, currentRoomIndexFor(e)); restored { + out = append(out, "_Harvest nodes in this room have re-emerged — the timeline isn't sure they were ever taken._") + } + return out } return nil } diff --git a/internal/plugin/dnd_zone_conditions_test.go b/internal/plugin/dnd_zone_conditions_test.go new file mode 100644 index 0000000..e90ff35 --- /dev/null +++ b/internal/plugin/dnd_zone_conditions_test.go @@ -0,0 +1,257 @@ +package plugin + +import "testing" + +// Phase R6 — zone-condition harvest interactions (§3 zone notes). + +func TestZoneConditionHarvestBlock_Underforge(t *testing.T) { + cases := []struct { + stacks int + action HarvestAction + want bool + }{ + {9, HarvestMine, false}, + {10, HarvestMine, true}, + {10, HarvestScavenge, false}, + {0, HarvestMine, false}, + } + for _, c := range cases { + exp := &Expedition{ZoneID: ZoneUnderforge, TemporalStack: c.stacks} + got := zoneConditionHarvestBlock(exp, c.action) != "" + if got != c.want { + t.Errorf("Underforge stacks=%d action=%s: got block=%v want=%v", c.stacks, c.action, got, c.want) + } + } +} + +func TestZoneConditionHarvestBlock_DragonsLair(t *testing.T) { + exp := &Expedition{ZoneID: ZoneDragonsLair, RegionState: map[string]any{}} + if zoneConditionHarvestBlock(exp, HarvestMine) != "" { + t.Errorf("Dragon's Lair pre-awake should not block") + } + exp.RegionState["infernax_awake"] = true + if zoneConditionHarvestBlock(exp, HarvestMine) == "" { + t.Errorf("Dragon's Lair post-awake should block") + } +} + +func TestZoneConditionHarvestBlock_Abyss(t *testing.T) { + cases := []struct { + stack int + want bool + }{ + {60, false}, + {80, false}, + {81, true}, + {99, true}, + } + for _, c := range cases { + exp := &Expedition{ZoneID: ZoneAbyssPortal, TemporalStack: c.stack} + got := zoneConditionHarvestBlock(exp, HarvestEssence) != "" + if got != c.want { + t.Errorf("Abyss stack=%d: got block=%v want=%v", c.stack, got, c.want) + } + } +} + +func TestZoneConditionHarvestDCMod_Underforge(t *testing.T) { + cases := []struct { + stacks int + action HarvestAction + want int + }{ + {6, HarvestMine, 0}, + {7, HarvestMine, 3}, + {9, HarvestMine, 3}, + {7, HarvestScavenge, 0}, + } + for _, c := range cases { + exp := &Expedition{ZoneID: ZoneUnderforge, TemporalStack: c.stacks} + got, _ := zoneConditionHarvestDCMod(exp, c.action) + if got != c.want { + t.Errorf("Underforge stacks=%d action=%s: got delta=%d want=%d", c.stacks, c.action, got, c.want) + } + } +} + +func TestZoneConditionHarvestDCMod_DragonsLair_Awareness(t *testing.T) { + cases := []struct { + day int + want int + }{ + {8, 0}, + {9, 4}, + {12, 4}, + } + for _, c := range cases { + exp := &Expedition{ZoneID: ZoneDragonsLair, CurrentDay: c.day, RegionState: map[string]any{}} + got, _ := zoneConditionHarvestDCMod(exp, HarvestMine) + if got != c.want { + t.Errorf("Dragon's Lair day=%d: got delta=%d want=%d", c.day, got, c.want) + } + } +} + +func TestZoneConditionHarvestDCMod_Abyss(t *testing.T) { + cases := []struct { + stack int + want int + }{ + {60, 0}, + {61, 5}, + {80, 5}, + {81, 0}, // 81+ is the block branch; DC mod stops contributing. + } + for _, c := range cases { + exp := &Expedition{ZoneID: ZoneAbyssPortal, TemporalStack: c.stack} + got, _ := zoneConditionHarvestDCMod(exp, HarvestMine) + if got != c.want { + t.Errorf("Abyss stack=%d: got delta=%d want=%d", c.stack, got, c.want) + } + } +} + +func TestZoneConditionHarvestDCMod_FeywildDoubleDay(t *testing.T) { + exp := &Expedition{ZoneID: ZoneFeywildCrossing, RegionState: map[string]any{ + "feywild_today": string(FeywildDistortionDouble), + }} + if got, _ := zoneConditionHarvestDCMod(exp, HarvestForage); got != -3 { + t.Errorf("Feywild double-day forage: got delta=%d want=-3", got) + } + if got, _ := zoneConditionHarvestDCMod(exp, HarvestMine); got != 0 { + t.Errorf("Feywild double-day mine: should not bonus, got delta=%d", got) + } + exp.RegionState["feywild_today"] = string(FeywildDistortionLoop) + if got, _ := zoneConditionHarvestDCMod(exp, HarvestForage); got != 0 { + t.Errorf("Feywild loop should not bonus forage DC, got delta=%d", got) + } +} + +func TestRollManorCursedTrinket(t *testing.T) { + // Wrong zone or wrong action: never fires. + if rollManorCursedTrinket(ZoneCryptValdris, HarvestScavenge, func(int) int { return 0 }) { + t.Errorf("non-Manor zone should never fire") + } + if rollManorCursedTrinket(ZoneManorBlackspire, HarvestForage, func(int) int { return 0 }) { + t.Errorf("non-scavenge action should never fire") + } + // 10% threshold: roll 9 fires, roll 10 does not. + if !rollManorCursedTrinket(ZoneManorBlackspire, HarvestScavenge, func(int) int { return 9 }) { + t.Errorf("roll=9 should fire (under 10%%)") + } + if rollManorCursedTrinket(ZoneManorBlackspire, HarvestScavenge, func(int) int { return 10 }) { + t.Errorf("roll=10 should not fire (boundary)") + } +} + +func TestRestoreHarvestNodesInRoom(t *testing.T) { + exp := &Expedition{ + ZoneID: ZoneFeywildCrossing, + CurrentRegion: "", + RegionState: map[string]any{ + regionStateHarvestKey: map[string]map[string][]HarvestNode{ + "": { + "0": { + {ResourceID: "fey_dust", CurrentCharges: 0, MaxCharges: 2}, + {ResourceID: "enchanted_petal", CurrentCharges: 1, MaxCharges: 2}, + }, + }, + }, + }, + } + if !restoreHarvestNodesInRoom(exp, 0) { + t.Fatalf("restore should report changes") + } + table := loadHarvestTable(exp) + got := table[""]["0"] + for _, n := range got { + if n.CurrentCharges != n.MaxCharges { + t.Errorf("node %s: charges=%d max=%d (expected restored)", n.ResourceID, n.CurrentCharges, n.MaxCharges) + } + } +} + +func TestRestoreHarvestNodesInRoom_NoOpEmpty(t *testing.T) { + exp := &Expedition{ZoneID: ZoneFeywildCrossing, RegionState: map[string]any{}} + if got := restoreHarvestNodesInRoom(exp, -1); got { + t.Errorf("negative roomIdx should no-op") + } +} + +func TestIsBladeWeapon(t *testing.T) { + cases := []struct { + it AdvItem + want bool + }{ + {AdvItem{Name: "Mithral Dagger", Type: "weapon"}, true}, + {AdvItem{Name: "Iron Longsword", Type: "weapon"}, true}, + {AdvItem{Name: "Battle Scimitar", Type: "weapon"}, true}, + {AdvItem{Name: "Heavy Mace", Type: "weapon"}, false}, + {AdvItem{Name: "Iron Dagger", Type: "consumable"}, false}, + {AdvItem{Name: "Drow Poison (diluted)", Type: "material"}, false}, + } + for _, c := range cases { + if got := isBladeWeapon(c.it); got != c.want { + t.Errorf("%s (type=%s): got %v want %v", c.it.Name, c.it.Type, got, c.want) + } + } +} + +func TestPickBladeWeapons_Coverage(t *testing.T) { + items := []AdvItem{ + {ID: 1, Name: "Mithral Dagger", Type: "weapon"}, + {ID: 2, Name: "Iron Mace", Type: "weapon"}, + {ID: 3, Name: "Iron Longsword", Type: "weapon"}, + {ID: 4, Name: "Drow Poison (diluted)", Type: "material"}, + } + picked, short := pickBladeWeapons(items, 2, nil) + if short != 0 { + t.Errorf("expected fully covered, got short=%d", short) + } + if len(picked) != 2 { + t.Errorf("expected 2 picks, got %d", len(picked)) + } + for _, id := range picked { + if id == 2 || id == 4 { + t.Errorf("picked non-blade id=%d", id) + } + } +} + +func TestPickBladeWeapons_Short(t *testing.T) { + items := []AdvItem{{ID: 1, Name: "Mithral Dagger", Type: "weapon"}} + picked, short := pickBladeWeapons(items, 2, nil) + if short != 1 { + t.Errorf("expected short=1, got %d", short) + } + if len(picked) != 1 { + t.Errorf("expected 1 pick, got %d", len(picked)) + } +} + +func TestPickBladeWeapons_ExcludesAlreadyConsumed(t *testing.T) { + items := []AdvItem{ + {ID: 1, Name: "Iron Dagger", Type: "weapon"}, + {ID: 2, Name: "Iron Sword", Type: "weapon"}, + } + picked, short := pickBladeWeapons(items, 1, []int64{1}) + if short != 0 || len(picked) != 1 || picked[0] != 2 { + t.Errorf("expected pick=[2] short=0; got pick=%v short=%d", picked, short) + } +} + +func TestThomCraftRecipes_DrowPoisonBlade_Defined(t *testing.T) { + r, ok := findRecipeByID("drow_poison_blade") + if !ok { + t.Fatalf("drow_poison_blade recipe not registered") + } + if r.BladeWeaponCount != 1 { + t.Errorf("expected BladeWeaponCount=1, got %d", r.BladeWeaponCount) + } + if _, ok := r.Ingredients["Drow Poison (diluted)"]; !ok { + t.Errorf("expected Drow Poison ingredient") + } + if r.OutputType != "weapon" { + t.Errorf("expected weapon output type, got %s", r.OutputType) + } +}