Files
gogobee/internal/plugin/dnd_expedition_harvest.go
prosolis dfa7beeb96 Adv 2.0 D&D polish: GM→DM rename, streamed zone combat, end-to-end scenario test
GM→DM rename across docs and code (GMNarrationType→DMNarrationType,
GMState→DMState, narration constants, comments) so the system reads as
"Dungeon Master" everywhere. Player-visible "GM mood" wording stays
where it appears in flavor.

Streamed zone/expedition combat: zone advance now stages patrol →
patrol play-by-play → patrol resolution → room intro → room play-by-play
→ final outcome through sendZoneCombatMessages with 2–3s pacing
(arena keeps its 5–8s window). Combat narrative lines pick up a compact
d20-vs-AC roll annotation for hit/crit/miss/block events.

Combat outcome polish: dndHPSnapshot lets narration show sheet HP
rather than legacy combat-engine HP, and markAdventureDead clears the
zombie state where hp_current was 0 but the legacy alive flag stayed
true after a D&D-layer KO.

Adv 2.0 announcement (ADVENTURE_2.0_ANNOUNCEMENT.md), README rewrite
covering the new layer, and adv2_scenario_test.go — a full
zone-run + expedition + harvest playthrough against a copy of the prod
DB asserting persisted state end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00

946 lines
29 KiB
Go

package plugin
// Phase R2 — Expedition harvest nodes & commands (full implementation).
// Spec: gogobee_resource_combat_integration.md §2 + §3.
//
// Architecture:
// - On !expedition start, ensureRegionRun spawns a DungeonRun for the
// current region and pins it via Expedition.RunID. Multi-region zones
// get one run per region, indexed in RegionState["region_runs"].
// - On !region travel, the outgoing region's run is abandoned and a
// fresh run is spawned for the new region.
// - HarvestNode tables are keyed by (regionKey, roomIdx) under
// RegionState["harvest_nodes"]. Each room's nodes are lazy-seeded
// from the §3 zone registry on first harvest in that room.
// - Harvest only succeeds in cleared rooms (entry rooms auto-pass;
// non-entry require roomIdx ∈ DungeonRun.RoomsCleared).
// - Long rest at camp replenishes every node across every room and
// region (§2.3).
//
// Deferred to R3 (combat-link):
// - Combat Interrupt rolls (§4.2)
// - RequiresKill resources (writer for RegionState["kills"] not yet wired)
import (
"encoding/json"
"fmt"
"math/rand/v2"
"strconv"
"strings"
"gogobee/internal/flavor"
"maunium.net/go/mautrix/id"
)
// RegionState slot keys used by the harvest layer.
//
// RegionState["harvest_nodes"][regionKey][roomKey] = []HarvestNode
// RegionState["region_runs"][regionKey] = runID string
const (
regionStateHarvestKey = "harvest_nodes"
regionStateRegionRuns = "region_runs"
)
// HarvestNode is one resource node in a specific room.
type HarvestNode struct {
ResourceID string `json:"resource_id"`
CurrentCharges int `json:"current_charges"`
MaxCharges int `json:"max_charges"`
}
// HarvestOutcome enumerates §2.2 outcomes.
type HarvestOutcome string
const (
OutcomeNothing HarvestOutcome = "nothing"
OutcomeCommon HarvestOutcome = "common"
OutcomeStandard HarvestOutcome = "standard"
OutcomeRich HarvestOutcome = "rich"
)
// resolveHarvestOutcome maps (roll, dc) to the §2.2 bracket.
func resolveHarvestOutcome(roll, dc int) HarvestOutcome {
delta := roll - dc
switch {
case delta >= 5:
return OutcomeRich
case delta >= 0:
return OutcomeStandard
case delta >= -4:
return OutcomeCommon
default:
return OutcomeNothing
}
}
// classHarvestBonus implements §2.1 +2 class affinity.
func classHarvestBonus(class DnDClass, action HarvestAction) int {
match := false
switch action {
case HarvestForage, HarvestFish:
match = class == ClassRanger
case HarvestMine:
match = class == ClassFighter
case HarvestScavenge:
match = class == ClassRogue
case HarvestEssence:
match = class == ClassMage
case HarvestCommune:
match = class == ClassCleric
}
if match {
return 2
}
return 0
}
// harvestActionAbility returns the §2.1 ability modifier for `action`.
func harvestActionAbility(c *DnDCharacter, action HarvestAction) int {
switch action {
case HarvestForage:
return abilityModifier(c.WIS)
case HarvestMine:
return abilityModifier(c.STR)
case HarvestScavenge:
return abilityModifier(c.INT)
case HarvestEssence:
return abilityModifier(c.INT)
case HarvestCommune:
return abilityModifier(c.WIS)
case HarvestFish:
return abilityModifier(c.DEX)
}
return 0
}
// regionHarvestKey returns the storage key for the player's current region.
// Single-region zones store under "".
func regionHarvestKey(e *Expedition) string { return e.CurrentRegion }
// roomHarvestKey returns the JSON-safe key for a 0-based room index.
func roomHarvestKey(roomIdx int) string { return strconv.Itoa(roomIdx) }
// loadHarvestNodes returns the (region, room)'s nodes, lazy-seeding from
// the §3 registry on first access. Caller must persist via
// saveHarvestNodes after mutation.
func loadHarvestNodes(e *Expedition, roomIdx int) []HarvestNode {
if e == nil {
return nil
}
if e.RegionState == nil {
e.RegionState = map[string]any{}
}
table := loadHarvestTable(e)
regionKey := regionHarvestKey(e)
roomKey := roomHarvestKey(roomIdx)
regionMap, ok := table[regionKey]
if !ok {
regionMap = map[string][]HarvestNode{}
table[regionKey] = regionMap
}
if existing, ok := regionMap[roomKey]; ok {
return existing
}
seeded := seedRoomNodes(e.ZoneID)
regionMap[roomKey] = seeded
table[regionKey] = regionMap
e.RegionState[regionStateHarvestKey] = table
return seeded
}
// saveHarvestNodes writes the supplied node slice into the (region, room)
// slot and persists the expedition state.
func saveHarvestNodes(e *Expedition, roomIdx int, nodes []HarvestNode) error {
if e.RegionState == nil {
e.RegionState = map[string]any{}
}
table := loadHarvestTable(e)
regionKey := regionHarvestKey(e)
roomKey := roomHarvestKey(roomIdx)
regionMap, ok := table[regionKey]
if !ok {
regionMap = map[string][]HarvestNode{}
}
regionMap[roomKey] = nodes
table[regionKey] = regionMap
e.RegionState[regionStateHarvestKey] = table
return persistRegionState(e)
}
// loadHarvestTable parses RegionState["harvest_nodes"] into the typed
// nested-map shape, regardless of how JSON unmarshalled it.
func loadHarvestTable(e *Expedition) map[string]map[string][]HarvestNode {
table := map[string]map[string][]HarvestNode{}
if e == nil || e.RegionState == nil {
return table
}
raw, ok := e.RegionState[regionStateHarvestKey]
if !ok {
return table
}
switch v := raw.(type) {
case map[string]map[string][]HarvestNode:
return v
default:
// JSON path: re-marshal the generic shape and re-parse into the
// typed nested shape.
b, err := json.Marshal(v)
if err != nil {
return table
}
_ = json.Unmarshal(b, &table)
}
return table
}
// seedRoomNodes constructs the initial node set for a single room from
// the zone registry. Each ZoneResource becomes one node sized to its
// MaxCharges entry.
func seedRoomNodes(zoneID ZoneID) []HarvestNode {
all := ZoneResourcesAll(zoneID)
nodes := make([]HarvestNode, 0, len(all))
for _, r := range all {
max := r.MaxCharges
if max <= 0 {
max = 1
}
nodes = append(nodes, HarvestNode{
ResourceID: r.ID,
CurrentCharges: max,
MaxCharges: max,
})
}
return nodes
}
// ReplenishHarvestNodes resets every node's CurrentCharges across every
// (region, room) tracked under this expedition. Called from the long-rest
// path (processOvernightCamp).
func ReplenishHarvestNodes(e *Expedition) error {
if e == nil {
return nil
}
table := loadHarvestTable(e)
if len(table) == 0 {
return nil
}
for regionKey, regionMap := range table {
for roomKey, nodes := range regionMap {
for i := range nodes {
nodes[i].CurrentCharges = nodes[i].MaxCharges
}
regionMap[roomKey] = nodes
}
table[regionKey] = regionMap
}
if e.RegionState == nil {
e.RegionState = map[string]any{}
}
e.RegionState[regionStateHarvestKey] = table
return persistRegionState(e)
}
// regionKillsContains reports whether RegionState["kills"][region] holds
// `kind`. Combat-link writes this list (R3); until then it's empty.
func regionKillsContains(e *Expedition, kind string) bool {
if e == nil || e.RegionState == nil || kind == "" {
return false
}
raw, ok := e.RegionState["kills"]
if !ok {
return false
}
table := map[string][]string{}
switch v := raw.(type) {
case map[string][]string:
table = v
case map[string]any:
if b, err := json.Marshal(v); err == nil {
_ = json.Unmarshal(b, &table)
}
default:
return false
}
for _, k := range table[regionHarvestKey(e)] {
if k == kind {
return true
}
}
return false
}
// pickAvailableNode picks the lowest-DC eligible node matching `action`
// in `nodes`. Returns the node index or -1 if none usable.
func pickAvailableNode(nodes []HarvestNode, e *Expedition, char *DnDCharacter, action HarvestAction) int {
bestIdx := -1
bestDC := 1<<31 - 1
for i := range nodes {
n := &nodes[i]
if n.CurrentCharges <= 0 {
continue
}
res, ok := FindZoneResource(e.ZoneID, n.ResourceID)
if !ok || res.Action != action {
continue
}
if res.ClassRestrict != "" && res.ClassRestrict != char.Class {
continue
}
if res.RequiresKill != "" && !regionKillsContains(e, res.RequiresKill) {
continue
}
if res.ConditionalEvent != "" && !regionEventActive(e, res.ConditionalEvent) {
continue
}
if res.DC < bestDC {
bestDC = res.DC
bestIdx = i
}
}
return bestIdx
}
// regionEventActive checks RegionState for an event flag (e.g. tidal_event).
func regionEventActive(e *Expedition, eventID string) bool {
if e == nil || e.RegionState == nil {
return false
}
switch eventID {
case "tidal_event":
v, _ := e.RegionState["tidal_active"].(bool)
return v
}
return false
}
// ── room-state helpers ──────────────────────────────────────────────────────
// currentRoomCleared reports whether the player's current room is safe
// for harvest. Entry rooms auto-clear; otherwise the index must be in
// run.RoomsCleared.
func currentRoomCleared(run *DungeonRun) bool {
if run == nil {
return false
}
if run.CurrentRoomType() == RoomEntry {
return true
}
for _, idx := range run.RoomsCleared {
if idx == run.CurrentRoom {
return true
}
}
return false
}
// ── command surface ────────────────────────────────────────────────────────
// handleHarvestCmd is the shared dispatcher for !forage/!mine/!scavenge/
// !essence/!commune. Resolves a single attempt and DMs the outcome.
func (p *AdventurePlugin) handleHarvestCmd(ctx MessageContext, action HarvestAction) error {
char, err := LoadDnDCharacter(ctx.Sender)
if err != nil || char == nil {
return p.SendDM(ctx.Sender, "No D&D character found. Run `!setup` first.")
}
exp, err := getActiveExpedition(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't load expedition state.")
}
if exp == nil {
// No expedition: fall through to single-session harvest if the
// player has an active !zone enter run. Single-session harvest
// lives in dnd_zone_harvest.go and stores nodes per-run rather
// than per-region.
run, _ := getActiveZoneRun(ctx.Sender)
if run == nil {
return p.SendDM(ctx.Sender, "You're not in a zone or expedition. Use `!zone enter <id>` or `!expedition start <zone>`.")
}
return p.handleStandaloneHarvest(ctx, char, action, run)
}
if action == HarvestFish && !isFishingZone(exp.ZoneID) {
return p.SendDM(ctx.Sender, "There's no water to fish in here. `!fish` works only in Forest of Shadows, Sunken Temple, Underdark, or Feywild Crossing.")
}
if exp.RunID == "" {
return p.SendDM(ctx.Sender, "No active region run. Try `!region` to refresh, or restart the expedition.")
}
run, err := getZoneRun(exp.RunID)
if err != nil || run == nil {
return p.SendDM(ctx.Sender, "Couldn't load region state.")
}
if !currentRoomCleared(run) {
return p.SendDM(ctx.Sender, fmt.Sprintf(
"You can't harvest here — the room (%s) isn't cleared. Resolve combat first.",
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)
if idx < 0 {
return p.SendDM(ctx.Sender, fmt.Sprintf(
"Nothing left to %s in this room. Try `!resources` to see what's still gatherable.", action))
}
res, _ := FindZoneResource(exp.ZoneID, nodes[idx].ResourceID)
// §4.2 Combat Interrupt roll — gates the harvest attempt.
zone, _ := getZone(exp.ZoneID)
interrupt, intTotal := resolveCombatInterrupt(
exp.ThreatLevel, int(zone.Tier), char.Class, exp.ZoneID, nil)
var interruptHeader string
switch interrupt {
case InterruptNoise:
_ = applyThreatDelta(exp.ID, 2, "harvest noise (§4.2)")
interruptHeader = fmt.Sprintf(
"_⚠ Noise drifts through the corridors (interrupt roll %d). Threat +2._\n\n",
intTotal)
case InterruptStandard, InterruptElite, InterruptPatrol:
var pre strings.Builder
pre.WriteString(fmt.Sprintf(
"**%s · %s** — interrupted before the roll (interrupt %d).\n\n",
strings.ToTitle(string(action)[:1])+string(action)[1:], res.Name, intTotal))
body, ended := p.runHarvestInterrupt(ctx.Sender, exp, run, zone, interrupt)
pre.WriteString(body)
if interrupt == InterruptElite && !ended {
pre.WriteString("\n\n_The node is still here — you dropped the harvest mid-strike._")
}
if interrupt == InterruptPatrol && !ended {
pre.WriteString("\n\n_The harvest is forfeit._")
}
// Persist node state untouched (no charge spent).
_ = saveHarvestNodes(exp, roomIdx, nodes)
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "interrupt",
fmt.Sprintf("%s %s kind=%d total=%d", string(action), res.ID, int(interrupt), intTotal), "")
return p.SendDM(ctx.Sender, pre.String())
}
roll := rand.IntN(20) + 1
mod := harvestActionAbility(char, action) + classHarvestBonus(char.Class, action)
if action == HarvestFish {
if adv, _ := loadAdvCharacter(ctx.Sender); adv != nil {
mod += fishingSkillBonus(adv.FishingSkill)
}
}
total := roll + mod
dcDelta, dcReason := zoneConditionHarvestDCMod(exp, action)
effectiveDC := res.DC + dcDelta
outcome := resolveHarvestOutcome(total, effectiveDC)
outcome = rangerRareCatchUpgrade(char.Class, action, outcome, nil)
var b strings.Builder
if interruptHeader != "" {
b.WriteString(interruptHeader)
}
if dist := feywildFishDistortion(exp.ZoneID, action, nil); dist != "" {
b.WriteString(dist)
}
verb := strings.ToTitle(string(action)[:1]) + string(action)[1:]
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:
b.WriteString(flavor.Pick(flavor.HarvestFail))
b.WriteString("\n\n_No yield. The node is still here — try again._")
case OutcomeCommon:
_ = grantHarvestYield(ctx.Sender, res, 1)
b.WriteString(harvestSuccessLine(action, exp.ZoneID))
b.WriteString(fmt.Sprintf("\n\n_+1 %s._", res.Name))
nodes[idx].CurrentCharges--
case OutcomeStandard:
qty := rand.IntN(3) + 1
_ = grantHarvestYield(ctx.Sender, res, qty)
b.WriteString(harvestSuccessLine(action, exp.ZoneID))
b.WriteString(fmt.Sprintf("\n\n_+%d %s._", qty, res.Name))
nodes[idx].CurrentCharges--
case OutcomeRich:
qty := rand.IntN(3) + 1
_ = grantHarvestYield(ctx.Sender, res, qty)
bonus := pickRichBonus(exp.ZoneID, res.ID)
bonusLine := ""
if bonus != nil {
_ = grantHarvestYield(ctx.Sender, *bonus, 1)
bonusLine = fmt.Sprintf(" + 1 %s (bonus)", bonus.Name)
}
b.WriteString(flavor.Pick(flavor.RichYield))
b.WriteString(fmt.Sprintf("\n\n_+%d %s%s._", qty, res.Name, bonusLine))
nodes[idx].CurrentCharges--
}
if outcome != OutcomeNothing && nodes[idx].CurrentCharges <= 0 {
b.WriteString("\n\n")
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), "")
}
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "harvest",
fmt.Sprintf("%s %s d20=%d total=%d dc=%d → %s",
string(action), res.ID, roll, total, res.DC, string(outcome)), "")
return p.SendDM(ctx.Sender, b.String())
}
// harvestSuccessLine picks the action's generic success pool and
// occasionally layers a zone-specific Harvest* postscript.
func harvestSuccessLine(action HarvestAction, zoneID ZoneID) string {
var generic []string
switch action {
case HarvestForage:
generic = flavor.HarvestForageSuccess
case HarvestMine:
generic = flavor.HarvestMineSuccess
case HarvestScavenge:
generic = flavor.HarvestScavengeSuccess
case HarvestEssence:
generic = flavor.HarvestEssenceSuccess
case HarvestCommune:
generic = flavor.HarvestCommuneSuccess
case HarvestFish:
generic = flavor.HarvestFishSuccess
}
line := flavor.Pick(generic)
if rand.IntN(3) == 0 {
if zoneLine := zoneHarvestLine(zoneID); zoneLine != "" {
return line + " " + zoneLine
}
}
return line
}
// zoneHarvestLine picks one entry from the zone-specific Harvest* pool.
func zoneHarvestLine(zoneID ZoneID) string {
switch zoneID {
case ZoneGoblinWarrens:
return flavor.Pick(flavor.HarvestGoblinWarrens)
case ZoneCryptValdris:
return flavor.Pick(flavor.HarvestCryptValdris)
case ZoneForestShadows:
return flavor.Pick(flavor.HarvestForestShadows)
case ZoneSunkenTemple:
return flavor.Pick(flavor.HarvestSunkenTemple)
case ZoneManorBlackspire:
return flavor.Pick(flavor.HarvestHauntedManor)
case ZoneUnderforge:
return flavor.Pick(flavor.HarvestUnderforge)
case ZoneUnderdark:
return flavor.Pick(flavor.HarvestUnderdark)
case ZoneFeywildCrossing:
return flavor.Pick(flavor.HarvestFeywild)
case ZoneDragonsLair:
return flavor.Pick(flavor.HarvestDragonsLair)
case ZoneAbyssPortal:
return flavor.Pick(flavor.HarvestAbyssPortal)
}
return ""
}
// pickRichBonus picks a higher-rarity zone resource (different ID, any
// action) as the §2.2 rich-yield bonus drop. Returns nil if no candidate.
func pickRichBonus(zoneID ZoneID, baseID string) *ZoneResource {
all := ZoneResourcesAll(zoneID)
candidates := make([]ZoneResource, 0, len(all))
for _, r := range all {
if r.ID == baseID {
continue
}
switch r.Rarity {
case RarityUncommon, RarityRare, RarityVeryRare:
candidates = append(candidates, r)
}
}
if len(candidates) == 0 {
return nil
}
pick := candidates[rand.IntN(len(candidates))]
return &pick
}
// grantHarvestYield deposits the resource into the player's inventory.
func grantHarvestYield(userID id.UserID, res ZoneResource, qty int) error {
if qty <= 0 {
return nil
}
if isHol, _ := isHolidayToday(); isHol {
qty++
}
tier := zoneTierFromID(res.ZoneID)
for i := 0; i < qty; i++ {
item := AdvItem{
Name: res.Name,
Type: "material",
Tier: tier,
Value: int64(res.SellValue),
SkillSource: string(res.Action),
}
if err := addAdvInventoryItem(userID, item); err != nil {
return err
}
}
return nil
}
// zoneTierFromID returns the zone's tier for sell-value bookkeeping.
func zoneTierFromID(zoneID ZoneID) int {
z, ok := getZone(zoneID)
if !ok {
return 1
}
return int(z.Tier)
}
// ── !resources ────────────────────────────────────────────────────────────
// handleResourcesCmd lists active nodes in the current room.
func (p *AdventurePlugin) handleResourcesCmd(ctx MessageContext) error {
exp, err := getActiveExpedition(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't load expedition state.")
}
if exp == nil {
return p.SendDM(ctx.Sender, "You're not on an expedition. Start one with `!expedition start <zone>`.")
}
char, err := LoadDnDCharacter(ctx.Sender)
if err != nil || char == nil {
return p.SendDM(ctx.Sender, "No D&D character found. Run `!setup` first.")
}
if exp.RunID == "" {
return p.SendDM(ctx.Sender, "No active region run. Try `!region` to refresh, or restart the expedition.")
}
run, err := getZoneRun(exp.RunID)
if err != nil || run == nil {
return p.SendDM(ctx.Sender, "Couldn't load region state.")
}
roomIdx := run.CurrentRoom
nodes := loadHarvestNodes(exp, roomIdx)
_ = saveHarvestNodes(exp, roomIdx, nodes) // persist seed if first touch
zone, _ := getZone(exp.ZoneID)
regionLabel := exp.CurrentRegion
if regionLabel == "" {
regionLabel = string(exp.ZoneID)
}
cleared := currentRoomCleared(run)
var b strings.Builder
b.WriteString(fmt.Sprintf("**Resources — %s · %s · Room %d/%d (%s)**\n\n",
zone.Display, regionLabel, run.CurrentRoom+1, run.TotalRooms, prettyRoomType(run.CurrentRoomType())))
if !cleared {
b.WriteString("_Room not cleared — harvest blocked until combat resolves._\n\n")
}
any := false
for _, n := range nodes {
res, ok := FindZoneResource(exp.ZoneID, n.ResourceID)
if !ok {
continue
}
gates := []string{}
if res.ClassRestrict != "" && res.ClassRestrict != char.Class {
gates = append(gates, fmt.Sprintf("%s only", strings.Title(string(res.ClassRestrict))))
}
if res.RequiresKill != "" && !regionKillsContains(exp, res.RequiresKill) {
gates = append(gates, "post-"+res.RequiresKill+" kill")
}
if res.ConditionalEvent != "" && !regionEventActive(exp, res.ConditionalEvent) {
gates = append(gates, res.ConditionalEvent+" only")
}
status := fmt.Sprintf("%d/%d", n.CurrentCharges, n.MaxCharges)
if n.CurrentCharges == 0 {
status = "depleted"
}
gateStr := ""
if len(gates) > 0 {
gateStr = " _(" + strings.Join(gates, "; ") + ")_"
}
b.WriteString(fmt.Sprintf("• `!%s` · **%s** (DC %d, %s) — %s%s\n",
res.Action, res.Name, res.DC, res.Rarity, status, gateStr))
any = true
}
if !any {
b.WriteString("_Zone has no harvest registry._")
} else {
b.WriteString("\n_Long rest at camp replenishes nodes across every room._")
}
return p.SendDM(ctx.Sender, b.String())
}
// ── region-run linkage (auto-spawn DungeonRun per region) ─────────────────
// regionRunsMap reads the regionKey→runID pointer table from RegionState.
func regionRunsMap(e *Expedition) map[string]string {
out := map[string]string{}
if e == nil || e.RegionState == nil {
return out
}
raw, ok := e.RegionState[regionStateRegionRuns]
if !ok {
return out
}
switch v := raw.(type) {
case map[string]string:
return v
case map[string]any:
for k, vv := range v {
if s, ok := vv.(string); ok {
out[k] = s
}
}
}
return out
}
// setRegionRun persists `runID` as the region's run pointer and rewrites
// exp.RunID to match. The caller is responsible for actually creating
// (or having created) the run. Pass "" to clear.
func setRegionRun(e *Expedition, regionKey, runID string) error {
if e.RegionState == nil {
e.RegionState = map[string]any{}
}
m := regionRunsMap(e)
if runID == "" {
delete(m, regionKey)
} else {
m[regionKey] = runID
}
e.RegionState[regionStateRegionRuns] = m
if err := persistRegionState(e); err != nil {
return err
}
e.RunID = runID
return setExpeditionRunID(e.ID, runID)
}
// ensureRegionRun guarantees a DungeonRun exists for the player's current
// region and that exp.RunID points at it. If a run already exists for
// the region, it is reloaded; otherwise a fresh one is started after
// abandoning any other active run for the player.
//
// charLevel is needed for the zone-tier gate inside startZoneRun.
func ensureRegionRun(exp *Expedition, charLevel int) (*DungeonRun, error) {
regionKey := regionHarvestKey(exp)
runs := regionRunsMap(exp)
if existing, ok := runs[regionKey]; ok && existing != "" {
run, err := getZoneRun(existing)
if err != nil {
return nil, err
}
if run != nil {
if exp.RunID != run.RunID {
if err := setExpeditionRunID(exp.ID, run.RunID); err != nil {
return nil, err
}
exp.RunID = run.RunID
}
return run, nil
}
}
// No run mapped for this region; if a different active run exists for
// the player (stale link from prior region), abandon it first.
if other, _ := getActiveZoneRun(id.UserID(exp.UserID)); other != nil {
if err := abandonZoneRunByID(other.RunID); err != nil {
return nil, err
}
}
run, err := startZoneRun(id.UserID(exp.UserID), exp.ZoneID, charLevel, nil)
if err != nil {
return nil, fmt.Errorf("ensureRegionRun: %w", err)
}
if err := setRegionRun(exp, regionKey, run.RunID); err != nil {
return nil, err
}
return run, nil
}
// retireRegionRun abandons the player's run for the given regionKey (or
// the current region if regionKey == "" matches CurrentRegion). Idempotent.
// Used by handleRegionCmd "travel" before transitioning.
func retireRegionRun(exp *Expedition, regionKey string) error {
runs := regionRunsMap(exp)
runID, ok := runs[regionKey]
if !ok || runID == "" {
return nil
}
if err := abandonZoneRunByID(runID); err != nil {
return err
}
return setRegionRun(exp, regionKey, "")
}
// retireAllRegionRuns abandons every region's linked run. Called from the
// expedition-end paths (abandon, complete, forced extract) so that the
// per-user "active zone run" guard stays clean for the next adventure.
func retireAllRegionRuns(exp *Expedition) error {
if exp == nil {
return nil
}
for region := range regionRunsMap(exp) {
if err := retireRegionRun(exp, region); err != nil {
return err
}
}
// Also clear any unmapped active run for the user (defensive).
if other, _ := getActiveZoneRun(id.UserID(exp.UserID)); other != nil {
_ = abandonZoneRunByID(other.RunID)
}
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
}