Adv 2.0 D&D Phase R R6: Zone-condition harvest polish

Wires the §3 zone-note interactions that were called out across the
resource doc but left for the polish pass.

Harvest gates (dnd_expedition_harvest.go):
- zoneConditionHarvestBlock — Underforge Heat 10 blocks !mine, Dragon's
  Lair infernax_awake blocks all harvesting, Abyss Instability 81+
  blocks all harvesting.
- zoneConditionHarvestDCMod — Underforge Heat 7-9 → +3 !mine DC,
  Dragon's Lair Awareness Pulse 3+ (day 9+) → +4 all DCs, Abyss 61-80
  → +5 all DCs, Feywild Double-Day → -3 !forage DC. Delta + reason
  shown inline on the dice line.
- restoreHarvestNodesInRoom — Time Loop hook (in-memory only; cycle
  flow persists downstream).
- Manor !scavenge: 10% Cursed Trinket secondary drop with one-time
  TwinBee warning gated by RegionState["manor_cursed_warned"].

Time Loop wiring (dnd_expedition_temporal.go):
- feywildTemporalPostRollover Loop branch now restores nodes in the
  current room and appends a re-emergence narration line.

Drow Poison Blade recipe (dnd_economy.go):
- ThomCraftRecipe gains BladeWeaponCount; pickBladeWeapons matches
  Type=="weapon" + blade keyword (sword/dagger/blade/scimitar/etc.).
- !craft list shows the blade slot with current inventory count.
- Recipe consumes 1× Drow Poison + 1× any blade weapon → Drow Poison
  Blade (sleep-on-crit for 3 combats, effect TODO).

Tests (dnd_zone_conditions_test.go, all DB-free): block branches per
zone, DC delta tables, manor cursed-trinket gate at 10% boundary,
restoreHarvestNodesInRoom in-memory roundtrip, isBladeWeapon /
pickBladeWeapons coverage + shortfall + exclude-already-consumed,
drow_poison_blade recipe shape.

Flavor reuse only — no new flavor file. Reasons-strings on the dice
line and the cursed-trinket warning are inline (Thom/TwinBee voices
in existing pools didn't fit harvest-blocking or warding contexts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-08 18:42:36 -07:00
parent c170adaf05
commit c3083637ac
4 changed files with 519 additions and 15 deletions

View File

@@ -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
}